summary.py 22 KB

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