summary.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. #!/usr/bin/env python3
  2. #
  3. # Script to summarize the outputs of other scripts. Operates on CSV files.
  4. #
  5. import collections as co
  6. import csv
  7. import functools as ft
  8. import glob
  9. import math as m
  10. import os
  11. import re
  12. CSV_PATHS = ['*.csv']
  13. # Defaults are common fields generated by other littlefs scripts
  14. MERGES = {
  15. 'add': (
  16. ['code_size', 'data_size', 'stack_frame', 'struct_size',
  17. 'coverage_lines', 'coverage_branches',
  18. 'test_passed'],
  19. lambda xs: sum(xs[1:], start=xs[0])
  20. ),
  21. 'mul': (
  22. [],
  23. lambda xs: m.prod(xs[1:], start=xs[0])
  24. ),
  25. 'min': (
  26. [],
  27. min
  28. ),
  29. 'max': (
  30. ['stack_limit', 'coverage_hits'],
  31. max
  32. ),
  33. 'avg': (
  34. [],
  35. lambda xs: sum(xs[1:], start=xs[0]) / len(xs)
  36. ),
  37. }
  38. def openio(path, mode='r'):
  39. if path == '-':
  40. if 'r' in mode:
  41. return os.fdopen(os.dup(sys.stdin.fileno()), 'r')
  42. else:
  43. return os.fdopen(os.dup(sys.stdout.fileno()), 'w')
  44. else:
  45. return open(path, mode)
  46. # integer fields
  47. class IntField(co.namedtuple('IntField', 'x')):
  48. __slots__ = ()
  49. def __new__(cls, x):
  50. if isinstance(x, IntField):
  51. return x
  52. if isinstance(x, str):
  53. try:
  54. x = int(x, 0)
  55. except ValueError:
  56. # also accept +-∞ and +-inf
  57. if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
  58. x = float('inf')
  59. elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
  60. x = float('-inf')
  61. else:
  62. raise
  63. return super().__new__(cls, x)
  64. def __int__(self):
  65. assert not m.isinf(self.x)
  66. return self.x
  67. def __float__(self):
  68. return float(self.x)
  69. def __str__(self):
  70. if self.x == float('inf'):
  71. return '∞'
  72. elif self.x == float('-inf'):
  73. return '-∞'
  74. else:
  75. return str(self.x)
  76. none = '%7s' % '-'
  77. def table(self):
  78. return '%7s' % (self,)
  79. diff_none = '%7s' % '-'
  80. diff_table = table
  81. def diff_diff(self, other):
  82. new = self.x if self else 0
  83. old = other.x if other else 0
  84. diff = new - old
  85. if diff == float('+inf'):
  86. return '%7s' % '+∞'
  87. elif diff == float('-inf'):
  88. return '%7s' % '-∞'
  89. else:
  90. return '%+7d' % diff
  91. def ratio(self, other):
  92. new = self.x if self else 0
  93. old = other.x if other else 0
  94. if m.isinf(new) and m.isinf(old):
  95. return 0.0
  96. elif m.isinf(new):
  97. return float('+inf')
  98. elif m.isinf(old):
  99. return float('-inf')
  100. elif not old and not new:
  101. return 0.0
  102. elif not old:
  103. return 1.0
  104. else:
  105. return (new-old) / old
  106. def __add__(self, other):
  107. return IntField(self.x + other.x)
  108. def __mul__(self, other):
  109. return IntField(self.x * other.x)
  110. def __lt__(self, other):
  111. return self.x < other.x
  112. def __gt__(self, other):
  113. return self.__class__.__lt__(other, self)
  114. def __le__(self, other):
  115. return not self.__gt__(other)
  116. def __ge__(self, other):
  117. return not self.__lt__(other)
  118. def __truediv__(self, n):
  119. if m.isinf(self.x):
  120. return self
  121. else:
  122. return IntField(round(self.x / n))
  123. # float fields
  124. class FloatField(co.namedtuple('FloatField', 'x')):
  125. __slots__ = ()
  126. def __new__(cls, x):
  127. if isinstance(x, FloatField):
  128. return x
  129. if isinstance(x, str):
  130. try:
  131. x = float(x)
  132. except ValueError:
  133. # also accept +-∞ and +-inf
  134. if re.match('^\s*\+?\s*(?:∞|inf)\s*$', x):
  135. x = float('inf')
  136. elif re.match('^\s*-\s*(?:∞|inf)\s*$', x):
  137. x = float('-inf')
  138. else:
  139. raise
  140. return super().__new__(cls, x)
  141. def __float__(self):
  142. return float(self.x)
  143. def __str__(self):
  144. if self.x == float('inf'):
  145. return '∞'
  146. elif self.x == float('-inf'):
  147. return '-∞'
  148. else:
  149. return '%.1f' % self.x
  150. none = IntField.none
  151. table = IntField.table
  152. diff_none = IntField.diff_none
  153. diff_table = IntField.diff_table
  154. diff_diff = IntField.diff_diff
  155. ratio = IntField.ratio
  156. __add__ = IntField.__add__
  157. __mul__ = IntField.__mul__
  158. __lt__ = IntField.__lt__
  159. __gt__ = IntField.__gt__
  160. __le__ = IntField.__le__
  161. __ge__ = IntField.__ge__
  162. def __truediv__(self, n):
  163. if m.isinf(self.x):
  164. return self
  165. else:
  166. return FloatField(self.x / n)
  167. # fractional fields, a/b
  168. class FracField(co.namedtuple('FracField', 'a,b')):
  169. __slots__ = ()
  170. def __new__(cls, a, b=None):
  171. if isinstance(a, FracField) and b is None:
  172. return a
  173. if isinstance(a, str) and b is None:
  174. a, b = a.split('/', 1)
  175. if b is None:
  176. b = a
  177. return super().__new__(cls, IntField(a), IntField(b))
  178. def __str__(self):
  179. return '%s/%s' % (self.a, self.b)
  180. none = '%11s %7s' % ('-', '-')
  181. def table(self):
  182. if not self.b.x:
  183. return self.none
  184. t = self.a.x/self.b.x
  185. return '%11s %7s' % (
  186. self,
  187. '∞%' if t == float('+inf')
  188. else '-∞%' if t == float('-inf')
  189. else '%.1f%%' % (100*t))
  190. diff_none = '%11s' % '-'
  191. def diff_table(self):
  192. if not self.b.x:
  193. return self.diff_none
  194. return '%11s' % (self,)
  195. def diff_diff(self, other):
  196. new_a, new_b = self if self else (IntField(0), IntField(0))
  197. old_a, old_b = other if other else (IntField(0), IntField(0))
  198. return '%11s' % ('%s/%s' % (
  199. new_a.diff_diff(old_a).strip(),
  200. new_b.diff_diff(old_b).strip()))
  201. def ratio(self, other):
  202. new_a, new_b = self if self else (IntField(0), IntField(0))
  203. old_a, old_b = other if other else (IntField(0), IntField(0))
  204. new = new_a.x/new_b.x if new_b.x else 1.0
  205. old = old_a.x/old_b.x if old_b.x else 1.0
  206. return new - old
  207. def __add__(self, other):
  208. return FracField(self.a + other.a, self.b + other.b)
  209. def __mul__(self, other):
  210. return FracField(self.a * other.a, self.b + other.b)
  211. def __lt__(self, other):
  212. self_r = self.a.x/self.b.x if self.b.x else float('-inf')
  213. other_r = other.a.x/other.b.x if other.b.x else float('-inf')
  214. return self_r < other_r
  215. def __gt__(self, other):
  216. return self.__class__.__lt__(other, self)
  217. def __le__(self, other):
  218. return not self.__gt__(other)
  219. def __ge__(self, other):
  220. return not self.__lt__(other)
  221. def __truediv__(self, n):
  222. return FracField(self.a / n, self.b / n)
  223. def homogenize(results, *,
  224. fields=None,
  225. merges=None,
  226. renames=None,
  227. types=None,
  228. **_):
  229. # rename fields?
  230. if renames is not None:
  231. results_ = []
  232. for r in results:
  233. results_.append({renames.get(k, k): v for k, v in r.items()})
  234. results = results_
  235. # find all fields
  236. if not fields:
  237. fields = co.OrderedDict()
  238. for r in results:
  239. # also remove None fields, these can get introduced by
  240. # csv.DictReader when header and rows mismatch
  241. fields.update((k, v) for k, v in r.items() if k is not None)
  242. fields = list(fields.keys())
  243. # go ahead and clean up none values, these can have a few forms
  244. results_ = []
  245. for r in results:
  246. results_.append({
  247. k: r[k] for k in fields
  248. if r.get(k) is not None and not(
  249. isinstance(r[k], str)
  250. and re.match('^\s*[+-]?\s*$', r[k]))})
  251. # find best type for all fields
  252. def try_(x, type):
  253. try:
  254. type(x)
  255. return True
  256. except ValueError:
  257. return False
  258. if types is None:
  259. types = {}
  260. for k in fields:
  261. if merges is not None and merges.get(k):
  262. for type in [IntField, FloatField, FracField]:
  263. if all(k not in r or try_(r[k], type) for r in results_):
  264. types[k] = type
  265. break
  266. else:
  267. print("no type matches field %r?" % k)
  268. sys.exit(-1)
  269. # homogenize types
  270. for k in fields:
  271. if k in types:
  272. for r in results_:
  273. if k in r:
  274. r[k] = types[k](r[k])
  275. return fields, types, results_
  276. def fold(results, *,
  277. fields=None,
  278. merges=None,
  279. by=None,
  280. **_):
  281. folding = co.OrderedDict()
  282. if by is None:
  283. by = [k for k in fields if k not in merges]
  284. for r in results:
  285. name = tuple(r.get(k) for k in by)
  286. if name not in folding:
  287. folding[name] = {k: [] for k in fields if k in merges}
  288. for k in fields:
  289. # drop all fields fields without a type
  290. if k in merges and k in r:
  291. folding[name][k].append(r[k])
  292. # merge fields, we need the count at this point for averages
  293. folded = []
  294. types = {}
  295. for name, r in folding.items():
  296. r_ = {}
  297. for k, vs in r.items():
  298. if vs:
  299. _, merge = MERGES[merges[k]]
  300. r_[k] = merge(vs)
  301. # drop all rows without any fields
  302. # and drop all empty keys
  303. if r_:
  304. folded.append(dict(
  305. {k: n for k, n in zip(by, name) if n},
  306. **r_))
  307. fields_ = by + [k for k in fields if k in merges]
  308. return fields_, folded
  309. def table(results, diff_results=None, *,
  310. fields=None,
  311. types=None,
  312. merges=None,
  313. by=None,
  314. sort=None,
  315. reverse_sort=None,
  316. summary=False,
  317. all=False,
  318. percent=False,
  319. **_):
  320. all_, all = all, __builtins__.all
  321. # fold
  322. if by is not None:
  323. fields, results = fold(results, fields=fields, merges=merges, by=by)
  324. if diff_results is not None:
  325. _, diff_results = fold(diff_results,
  326. fields=fields, merges=merges, by=by)
  327. table = {
  328. tuple(r.get(k,'') for k in fields if k not in merges): r
  329. for r in results}
  330. diff_table = {
  331. tuple(r.get(k,'') for k in fields if k not in merges): r
  332. for r in diff_results or []}
  333. # sort, note that python's sort is stable
  334. names = list(table.keys() | diff_table.keys())
  335. names.sort()
  336. if diff_results is not None:
  337. names.sort(key=lambda n: [
  338. -types[k].ratio(
  339. table.get(n,{}).get(k),
  340. diff_table.get(n,{}).get(k))
  341. for k in fields if k in merges])
  342. if sort:
  343. names.sort(key=lambda n: tuple(
  344. (table[n][k],) if k in table.get(n,{}) else ()
  345. for k in sort),
  346. reverse=True)
  347. elif reverse_sort:
  348. names.sort(key=lambda n: tuple(
  349. (table[n][k],) if k in table.get(n,{}) else ()
  350. for k in reverse_sort),
  351. reverse=False)
  352. # print header
  353. print('%-36s' % ('%s%s' % (
  354. ','.join(k for k in fields if k not in merges),
  355. ' (%d added, %d removed)' % (
  356. sum(1 for n in table if n not in diff_table),
  357. sum(1 for n in diff_table if n not in table))
  358. if diff_results is not None and not percent else '')
  359. if not summary else ''),
  360. end='')
  361. if diff_results is None:
  362. print(' %s' % (
  363. ' '.join(k.rjust(len(types[k].none))
  364. for k in fields if k in merges)))
  365. elif percent:
  366. print(' %s' % (
  367. ' '.join(k.rjust(len(types[k].diff_none))
  368. for k in fields if k in merges)))
  369. else:
  370. print(' %s %s %s' % (
  371. ' '.join(('o'+k).rjust(len(types[k].diff_none))
  372. for k in fields if k in merges),
  373. ' '.join(('n'+k).rjust(len(types[k].diff_none))
  374. for k in fields if k in merges),
  375. ' '.join(('d'+k).rjust(len(types[k].diff_none))
  376. for k in fields if k in merges)))
  377. # print entries
  378. if not summary:
  379. for name in names:
  380. r = table.get(name, {})
  381. if diff_results is not None:
  382. diff_r = diff_table.get(name, {})
  383. ratios = [types[k].ratio(r.get(k), diff_r.get(k))
  384. for k in fields if k in merges]
  385. if not any(ratios) and not all_:
  386. continue
  387. print('%-36s' % ','.join(name), end='')
  388. if diff_results is None:
  389. print(' %s' % (
  390. ' '.join(r[k].table()
  391. if k in r else types[k].none
  392. for k in fields if k in merges)))
  393. elif percent:
  394. print(' %s%s' % (
  395. ' '.join(r[k].diff_table()
  396. if k in r else types[k].diff_none
  397. for k in fields if k in merges),
  398. ' (%s)' % ', '.join(
  399. '+∞%' if t == float('+inf')
  400. else '-∞%' if t == float('-inf')
  401. else '%+.1f%%' % (100*t)
  402. for t in ratios)))
  403. else:
  404. print(' %s %s %s%s' % (
  405. ' '.join(diff_r[k].diff_table()
  406. if k in diff_r else types[k].diff_none
  407. for k in fields if k in merges),
  408. ' '.join(r[k].diff_table()
  409. if k in r else types[k].diff_none
  410. for k in fields if k in merges),
  411. ' '.join(types[k].diff_diff(r.get(k), diff_r.get(k))
  412. if k in r or k in diff_r else types[k].diff_none
  413. for k in fields if k in merges),
  414. ' (%s)' % ', '.join(
  415. '+∞%' if t == float('+inf')
  416. else '-∞%' if t == float('-inf')
  417. else '%+.1f%%' % (100*t)
  418. for t in ratios
  419. if t)
  420. if any(ratios) else ''))
  421. # print total
  422. _, total = fold(results, fields=fields, merges=merges, by=[])
  423. r = total[0] if total else {}
  424. if diff_results is not None:
  425. _, diff_total = fold(diff_results,
  426. fields=fields, merges=merges, by=[])
  427. diff_r = diff_total[0] if diff_total else {}
  428. ratios = [types[k].ratio(r.get(k), diff_r.get(k))
  429. for k in fields if k in merges]
  430. print('%-36s' % 'TOTAL', end='')
  431. if diff_results is None:
  432. print(' %s' % (
  433. ' '.join(r[k].table()
  434. if k in r else types[k].none
  435. for k in fields if k in merges)))
  436. elif percent:
  437. print(' %s%s' % (
  438. ' '.join(r[k].diff_table()
  439. if k in r else types[k].diff_none
  440. for k in fields if k in merges),
  441. ' (%s)' % ', '.join(
  442. '+∞%' if t == float('+inf')
  443. else '-∞%' if t == float('-inf')
  444. else '%+.1f%%' % (100*t)
  445. for t in ratios)))
  446. else:
  447. print(' %s %s %s%s' % (
  448. ' '.join(diff_r[k].diff_table()
  449. if k in diff_r else types[k].diff_none
  450. for k in fields if k in merges),
  451. ' '.join(r[k].diff_table()
  452. if k in r else types[k].diff_none
  453. for k in fields if k in merges),
  454. ' '.join(types[k].diff_diff(r.get(k), diff_r.get(k))
  455. if k in r or k in diff_r else types[k].diff_none
  456. for k in fields if k in merges),
  457. ' (%s)' % ', '.join(
  458. '+∞%' if t == float('+inf')
  459. else '-∞%' if t == float('-inf')
  460. else '%+.1f%%' % (100*t)
  461. for t in ratios
  462. if t)
  463. if any(ratios) else ''))
  464. def main(csv_paths, *, fields=None, by=None, **args):
  465. # figure out what fields to use
  466. renames = {}
  467. if fields is not None:
  468. fields_ = []
  469. for name in fields:
  470. if '=' in name:
  471. a, b = name.split('=', 1)
  472. renames[b] = a
  473. name = a
  474. fields_.append(name)
  475. fields = fields_
  476. if by is not None:
  477. by_ = []
  478. for name in by:
  479. if '=' in name:
  480. a, b = name.split('=', 1)
  481. renames[b] = a
  482. name = a
  483. by_.append(name)
  484. by = by_
  485. # include 'by' fields in fields, it doesn't make sense to not
  486. if fields is not None and by is not None:
  487. fields[:0] = [k for k in by if k not in fields]
  488. # use preconfigured merge operations unless any merge operation is
  489. # explictly specified
  490. merge_args = (args
  491. if any(args.get(m) for m in MERGES.keys())
  492. else {m: k for m, (k, _) in MERGES.items()})
  493. merges = {}
  494. for m in MERGES.keys():
  495. for k in merge_args.get(m, []):
  496. if k in merges:
  497. print("conflicting merge type for field %r?" % k)
  498. sys.exit(-1)
  499. merges[k] = m
  500. # allow renames to apply to merges
  501. for m in MERGES.keys():
  502. for k in merge_args.get(m, []):
  503. if renames.get(k, k) not in merges:
  504. merges[renames.get(k, k)] = m
  505. # ignore merges that conflict with 'by' fields
  506. if by is not None:
  507. for k in by:
  508. if k in merges:
  509. del merges[k]
  510. # find CSV files
  511. paths = []
  512. for path in csv_paths:
  513. if os.path.isdir(path):
  514. path = path + '/*.csv'
  515. for path in glob.glob(path):
  516. paths.append(path)
  517. if not paths:
  518. print('no .csv files found in %r?' % csv_paths)
  519. sys.exit(-1)
  520. results = []
  521. for path in paths:
  522. try:
  523. with openio(path) as f:
  524. reader = csv.DictReader(f)
  525. for r in reader:
  526. results.append(r)
  527. except FileNotFoundError:
  528. pass
  529. # homogenize
  530. fields, types, results = homogenize(results,
  531. fields=fields, merges=merges, renames=renames)
  532. # fold to remove duplicates
  533. fields, results = fold(results,
  534. fields=fields, merges=merges)
  535. # write results to CSV
  536. if args.get('output'):
  537. with openio(args['output'], 'w') as f:
  538. writer = csv.DictWriter(f, fields)
  539. writer.writeheader()
  540. for r in results:
  541. writer.writerow(r)
  542. # find previous results?
  543. if args.get('diff'):
  544. diff_results = []
  545. try:
  546. with openio(args['diff']) as f:
  547. reader = csv.DictReader(f)
  548. for r in reader:
  549. diff_results.append(r)
  550. except FileNotFoundError:
  551. pass
  552. # homogenize
  553. _, _, diff_results = homogenize(diff_results,
  554. fields=fields, merges=merges, renames=renames, types=types)
  555. # fold to remove duplicates
  556. _, diff_results = fold(diff_results,
  557. fields=fields, merges=merges)
  558. # print table
  559. if not args.get('quiet'):
  560. table(
  561. results,
  562. diff_results if args.get('diff') else None,
  563. fields=fields,
  564. types=types,
  565. merges=merges,
  566. by=by,
  567. **args)
  568. if __name__ == "__main__":
  569. import argparse
  570. import sys
  571. parser = argparse.ArgumentParser(
  572. description="Summarize measurements in CSV files.")
  573. parser.add_argument(
  574. 'csv_paths',
  575. nargs='*',
  576. default=CSV_PATHS,
  577. help="Description of where to find *.csv files. May be a directory "
  578. "or list of paths. Defaults to %(default)r.")
  579. parser.add_argument(
  580. '-q', '--quiet',
  581. action='store_true',
  582. help="Don't show anything, useful with -o.")
  583. parser.add_argument(
  584. '-o', '--output',
  585. help="Specify CSV file to store results.")
  586. parser.add_argument(
  587. '-d', '--diff',
  588. help="Specify CSV file to diff against.")
  589. parser.add_argument(
  590. '-a', '--all',
  591. action='store_true',
  592. help="Show all, not just the ones that changed.")
  593. parser.add_argument(
  594. '-p', '--percent',
  595. action='store_true',
  596. help="Only show percentage change, not a full diff.")
  597. parser.add_argument(
  598. '-f', '--fields',
  599. type=lambda x: [x.strip() for x in x.split(',')],
  600. help="Only show these fields. Can rename fields "
  601. "with old_name=new_name.")
  602. parser.add_argument(
  603. '-b', '--by',
  604. type=lambda x: [x.strip() for x in x.split(',')],
  605. help="Group by these fields. Can rename fields "
  606. "with old_name=new_name.")
  607. parser.add_argument(
  608. '--add',
  609. type=lambda x: [x.strip() for x in x.split(',')],
  610. help="Add these fields when merging.")
  611. parser.add_argument(
  612. '--mul',
  613. type=lambda x: [x.strip() for x in x.split(',')],
  614. help="Multiply these fields when merging.")
  615. parser.add_argument(
  616. '--min',
  617. type=lambda x: [x.strip() for x in x.split(',')],
  618. help="Take the minimum of these fields when merging.")
  619. parser.add_argument(
  620. '--max',
  621. type=lambda x: [x.strip() for x in x.split(',')],
  622. help="Take the maximum of these fields when merging.")
  623. parser.add_argument(
  624. '--avg',
  625. type=lambda x: [x.strip() for x in x.split(',')],
  626. help="Average these fields when merging.")
  627. parser.add_argument(
  628. '-s', '--sort',
  629. type=lambda x: [x.strip() for x in x.split(',')],
  630. help="Sort by these fields.")
  631. parser.add_argument(
  632. '-S', '--reverse-sort',
  633. type=lambda x: [x.strip() for x in x.split(',')],
  634. help="Sort by these fields, but backwards.")
  635. parser.add_argument(
  636. '-Y', '--summary',
  637. action='store_true',
  638. help="Only show the totals.")
  639. sys.exit(main(**{k: v
  640. for k, v in vars(parser.parse_args()).items()
  641. if v is not None}))