plot.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. #!/usr/bin/env python3
  2. #
  3. # Plot CSV files in terminal.
  4. #
  5. # Example:
  6. # ./scripts/plot.py bench.csv -xSIZE -ybench_read -W80 -H17
  7. #
  8. # Copyright (c) 2022, The littlefs authors.
  9. # SPDX-License-Identifier: BSD-3-Clause
  10. #
  11. import collections as co
  12. import csv
  13. import io
  14. import itertools as it
  15. import math as m
  16. import os
  17. import shutil
  18. import time
  19. try:
  20. import inotify_simple
  21. except ModuleNotFoundError:
  22. inotify_simple = None
  23. COLORS = [
  24. '1;34', # bold blue
  25. '1;31', # bold red
  26. '1;32', # bold green
  27. '1;35', # bold purple
  28. '1;33', # bold yellow
  29. '1;36', # bold cyan
  30. '34', # blue
  31. '31', # red
  32. '32', # green
  33. '35', # purple
  34. '33', # yellow
  35. '36', # cyan
  36. ]
  37. CHARS_DOTS = " .':"
  38. CHARS_BRAILLE = (
  39. '⠀⢀⡀⣀⠠⢠⡠⣠⠄⢄⡄⣄⠤⢤⡤⣤' '⠐⢐⡐⣐⠰⢰⡰⣰⠔⢔⡔⣔⠴⢴⡴⣴'
  40. '⠂⢂⡂⣂⠢⢢⡢⣢⠆⢆⡆⣆⠦⢦⡦⣦' '⠒⢒⡒⣒⠲⢲⡲⣲⠖⢖⡖⣖⠶⢶⡶⣶'
  41. '⠈⢈⡈⣈⠨⢨⡨⣨⠌⢌⡌⣌⠬⢬⡬⣬' '⠘⢘⡘⣘⠸⢸⡸⣸⠜⢜⡜⣜⠼⢼⡼⣼'
  42. '⠊⢊⡊⣊⠪⢪⡪⣪⠎⢎⡎⣎⠮⢮⡮⣮' '⠚⢚⡚⣚⠺⢺⡺⣺⠞⢞⡞⣞⠾⢾⡾⣾'
  43. '⠁⢁⡁⣁⠡⢡⡡⣡⠅⢅⡅⣅⠥⢥⡥⣥' '⠑⢑⡑⣑⠱⢱⡱⣱⠕⢕⡕⣕⠵⢵⡵⣵'
  44. '⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷'
  45. '⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽'
  46. '⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿')
  47. SI_PREFIXES = {
  48. 18: 'E',
  49. 15: 'P',
  50. 12: 'T',
  51. 9: 'G',
  52. 6: 'M',
  53. 3: 'K',
  54. 0: '',
  55. -3: 'm',
  56. -6: 'u',
  57. -9: 'n',
  58. -12: 'p',
  59. -15: 'f',
  60. -18: 'a',
  61. }
  62. # format a number to a strict character width using SI prefixes
  63. def si(x, w=4):
  64. if x == 0:
  65. return '0'
  66. # figure out prefix and scale
  67. p = 3*int(m.log(abs(x)*10, 10**3))
  68. p = min(18, max(-18, p))
  69. # format with enough digits
  70. s = '%.*f' % (w, abs(x) / (10.0**p))
  71. s = s.lstrip('0')
  72. # truncate but only digits that follow the dot
  73. if '.' in s:
  74. s = s[:max(s.find('.'), w-(2 if x < 0 else 1))]
  75. s = s.rstrip('0')
  76. s = s.rstrip('.')
  77. return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p])
  78. def openio(path, mode='r', buffering=-1):
  79. # allow '-' for stdin/stdout
  80. if path == '-':
  81. if mode == 'r':
  82. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  83. else:
  84. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  85. else:
  86. return open(path, mode, buffering)
  87. def inotifywait(paths):
  88. # wait for interesting events
  89. inotify = inotify_simple.INotify()
  90. flags = (inotify_simple.flags.ATTRIB
  91. | inotify_simple.flags.CREATE
  92. | inotify_simple.flags.DELETE
  93. | inotify_simple.flags.DELETE_SELF
  94. | inotify_simple.flags.MODIFY
  95. | inotify_simple.flags.MOVED_FROM
  96. | inotify_simple.flags.MOVED_TO
  97. | inotify_simple.flags.MOVE_SELF)
  98. # recurse into directories
  99. for path in paths:
  100. if os.path.isdir(path):
  101. for dir, _, files in os.walk(path):
  102. inotify.add_watch(dir, flags)
  103. for f in files:
  104. inotify.add_watch(os.path.join(dir, f), flags)
  105. else:
  106. inotify.add_watch(path, flags)
  107. # wait for event
  108. inotify.read()
  109. class LinesIO:
  110. def __init__(self, maxlen=None):
  111. self.maxlen = maxlen
  112. self.lines = co.deque(maxlen=maxlen)
  113. self.tail = io.StringIO()
  114. # trigger automatic sizing
  115. if maxlen == 0:
  116. self.resize(0)
  117. def write(self, s):
  118. # note using split here ensures the trailing string has no newline
  119. lines = s.split('\n')
  120. if len(lines) > 1 and self.tail.getvalue():
  121. self.tail.write(lines[0])
  122. lines[0] = self.tail.getvalue()
  123. self.tail = io.StringIO()
  124. self.lines.extend(lines[:-1])
  125. if lines[-1]:
  126. self.tail.write(lines[-1])
  127. def resize(self, maxlen):
  128. self.maxlen = maxlen
  129. if maxlen == 0:
  130. maxlen = shutil.get_terminal_size((80, 5))[1]
  131. if maxlen != self.lines.maxlen:
  132. self.lines = co.deque(self.lines, maxlen=maxlen)
  133. canvas_lines = 1
  134. def draw(self):
  135. # did terminal size change?
  136. if self.maxlen == 0:
  137. self.resize(0)
  138. # first thing first, give ourself a canvas
  139. while LinesIO.canvas_lines < len(self.lines):
  140. sys.stdout.write('\n')
  141. LinesIO.canvas_lines += 1
  142. # clear the bottom of the canvas if we shrink
  143. shrink = LinesIO.canvas_lines - len(self.lines)
  144. if shrink > 0:
  145. for i in range(shrink):
  146. sys.stdout.write('\r')
  147. if shrink-1-i > 0:
  148. sys.stdout.write('\x1b[%dA' % (shrink-1-i))
  149. sys.stdout.write('\x1b[K')
  150. if shrink-1-i > 0:
  151. sys.stdout.write('\x1b[%dB' % (shrink-1-i))
  152. sys.stdout.write('\x1b[%dA' % shrink)
  153. LinesIO.canvas_lines = len(self.lines)
  154. for i, line in enumerate(self.lines):
  155. # move cursor, clear line, disable/reenable line wrapping
  156. sys.stdout.write('\r')
  157. if len(self.lines)-1-i > 0:
  158. sys.stdout.write('\x1b[%dA' % (len(self.lines)-1-i))
  159. sys.stdout.write('\x1b[K')
  160. sys.stdout.write('\x1b[?7l')
  161. sys.stdout.write(line)
  162. sys.stdout.write('\x1b[?7h')
  163. if len(self.lines)-1-i > 0:
  164. sys.stdout.write('\x1b[%dB' % (len(self.lines)-1-i))
  165. sys.stdout.flush()
  166. # parse different data representations
  167. def dat(x):
  168. # allow the first part of an a/b fraction
  169. if '/' in x:
  170. x, _ = x.split('/', 1)
  171. # first try as int
  172. try:
  173. return int(x, 0)
  174. except ValueError:
  175. pass
  176. # then try as float
  177. try:
  178. x = float(x)
  179. # just don't allow infinity or nan
  180. if m.isinf(x) or m.isnan(x):
  181. raise ValueError("invalid dat %r" % x)
  182. except ValueError:
  183. pass
  184. # else give up
  185. raise ValueError("invalid dat %r" % x)
  186. # a hack log10 that preserves sign, and passes zero as zero
  187. def slog10(x):
  188. if x == 0:
  189. return x
  190. elif x > 0:
  191. return m.log10(x)
  192. else:
  193. return -m.log10(-x)
  194. class Plot:
  195. def __init__(self, width, height, *,
  196. xlim=None,
  197. ylim=None,
  198. xlog=False,
  199. ylog=False,
  200. **_):
  201. self.width = width
  202. self.height = height
  203. self.xlim = xlim or (0, width)
  204. self.ylim = ylim or (0, height)
  205. self.xlog = xlog
  206. self.ylog = ylog
  207. self.grid = [('',False)]*(self.width*self.height)
  208. def scale(self, x, y):
  209. # scale and clamp
  210. try:
  211. if self.xlog:
  212. x = int(self.width * (
  213. (slog10(x)-slog10(self.xlim[0]))
  214. / (slog10(self.xlim[1])-slog10(self.xlim[0]))))
  215. else:
  216. x = int(self.width * (
  217. (x-self.xlim[0])
  218. / (self.xlim[1]-self.xlim[0])))
  219. if self.ylog:
  220. y = int(self.height * (
  221. (slog10(y)-slog10(self.ylim[0]))
  222. / (slog10(self.ylim[1])-slog10(self.ylim[0]))))
  223. else:
  224. y = int(self.height * (
  225. (y-self.ylim[0])
  226. / (self.ylim[1]-self.ylim[0])))
  227. except ZeroDivisionError:
  228. x = 0
  229. y = 0
  230. return x, y
  231. def point(self, x, y, *,
  232. color=COLORS[0],
  233. char=True):
  234. # scale
  235. x, y = self.scale(x, y)
  236. # ignore out of bounds points
  237. if x >= 0 and x < self.width and y >= 0 and y < self.height:
  238. self.grid[x + y*self.width] = (color, char)
  239. def line(self, x1, y1, x2, y2, *,
  240. color=COLORS[0],
  241. char=True):
  242. # scale
  243. x1, y1 = self.scale(x1, y1)
  244. x2, y2 = self.scale(x2, y2)
  245. # incremental error line algorithm
  246. ex = abs(x2 - x1)
  247. ey = -abs(y2 - y1)
  248. dx = +1 if x1 < x2 else -1
  249. dy = +1 if y1 < y2 else -1
  250. e = ex + ey
  251. while True:
  252. if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height:
  253. self.grid[x1 + y1*self.width] = (color, char)
  254. e2 = 2*e
  255. if x1 == x2 and y1 == y2:
  256. break
  257. if e2 > ey:
  258. e += ey
  259. x1 += dx
  260. if x1 == x2 and y1 == y2:
  261. break
  262. if e2 < ex:
  263. e += ex
  264. y1 += dy
  265. if x2 >= 0 and x2 < self.width and y2 >= 0 and y2 < self.height:
  266. self.grid[x2 + y2*self.width] = (color, char)
  267. def plot(self, coords, *,
  268. color=COLORS[0],
  269. char=True,
  270. line_char=True):
  271. # draw lines
  272. if line_char:
  273. for (x1, y1), (x2, y2) in zip(coords, coords[1:]):
  274. if y1 is not None and y2 is not None:
  275. self.line(x1, y1, x2, y2,
  276. color=color,
  277. char=line_char)
  278. # draw points
  279. if char and (not line_char or char is not True):
  280. for x, y in coords:
  281. if y is not None:
  282. self.point(x, y,
  283. color=color,
  284. char=char)
  285. def draw(self, row, *,
  286. dots=False,
  287. braille=False,
  288. color=False,
  289. **_):
  290. # scale if needed
  291. if braille:
  292. xscale, yscale = 2, 4
  293. elif dots:
  294. xscale, yscale = 1, 2
  295. else:
  296. xscale, yscale = 1, 1
  297. y = self.height//yscale-1 - row
  298. row_ = []
  299. for x in range(self.width//xscale):
  300. best_f = ''
  301. best_c = False
  302. # encode into a byte
  303. b = 0
  304. for i in range(xscale*yscale):
  305. f, c = self.grid[x*xscale+(xscale-1-(i%xscale))
  306. + (y*yscale+(i//xscale))*self.width]
  307. if c:
  308. b |= 1 << i
  309. if f:
  310. best_f = f
  311. if c and c is not True:
  312. best_c = c
  313. # use byte to lookup character
  314. if b:
  315. if best_c:
  316. c = best_c
  317. elif braille:
  318. c = CHARS_BRAILLE[b]
  319. else:
  320. c = CHARS_DOTS[b]
  321. else:
  322. c = ' '
  323. # color?
  324. if b and color and best_f:
  325. c = '\x1b[%sm%s\x1b[m' % (best_f, c)
  326. # draw axis in blank spaces
  327. if not b:
  328. zx, zy = self.scale(0, 0)
  329. if x == zx // xscale and y == zy // yscale:
  330. c = '+'
  331. elif x == zx // xscale and y == 0:
  332. c = 'v'
  333. elif x == zx // xscale and y == self.height//yscale-1:
  334. c = '^'
  335. elif y == zy // yscale and x == 0:
  336. c = '<'
  337. elif y == zy // yscale and x == self.width//xscale-1:
  338. c = '>'
  339. elif x == zx // xscale:
  340. c = '|'
  341. elif y == zy // yscale:
  342. c = '-'
  343. row_.append(c)
  344. return ''.join(row_)
  345. def collect(csv_paths, renames=[]):
  346. # collect results from CSV files
  347. results = []
  348. for path in csv_paths:
  349. try:
  350. with openio(path) as f:
  351. reader = csv.DictReader(f, restval='')
  352. for r in reader:
  353. results.append(r)
  354. except FileNotFoundError:
  355. pass
  356. if renames:
  357. for r in results:
  358. # make a copy so renames can overlap
  359. r_ = {}
  360. for new_k, old_k in renames:
  361. if old_k in r:
  362. r_[new_k] = r[old_k]
  363. r.update(r_)
  364. return results
  365. def dataset(results, x=None, y=None, define=[]):
  366. # organize by 'by', x, and y
  367. dataset = {}
  368. i = 0
  369. for r in results:
  370. # filter results by matching defines
  371. if not all(k in r and r[k] in vs for k, vs in define):
  372. continue
  373. # find xs
  374. if x is not None:
  375. if x not in r:
  376. continue
  377. try:
  378. x_ = dat(r[x])
  379. except ValueError:
  380. continue
  381. else:
  382. x_ = i
  383. i += 1
  384. # find ys
  385. if y is not None:
  386. if y not in r:
  387. y_ = None
  388. else:
  389. try:
  390. y_ = dat(r[y])
  391. except ValueError:
  392. y_ = None
  393. else:
  394. y_ = None
  395. if y_ is not None:
  396. dataset[x_] = y_ + dataset.get(x_, 0)
  397. else:
  398. dataset[x_] = y_ or dataset.get(x_, None)
  399. return dataset
  400. def datasets(results, by=None, x=None, y=None, define=[]):
  401. # filter results by matching defines
  402. results_ = []
  403. for r in results:
  404. if all(k in r and r[k] in vs for k, vs in define):
  405. results_.append(r)
  406. results = results_
  407. # if y not specified, try to guess from data
  408. if y is None:
  409. y = co.OrderedDict()
  410. for r in results:
  411. for k, v in r.items():
  412. if (by is None or k not in by) and v.strip():
  413. try:
  414. dat(v)
  415. y[k] = True
  416. except ValueError:
  417. y[k] = False
  418. y = list(k for k,v in y.items() if v)
  419. if by is not None:
  420. # find all 'by' values
  421. ks = set()
  422. for r in results:
  423. ks.add(tuple(r.get(k, '') for k in by))
  424. ks = sorted(ks)
  425. # collect all datasets
  426. datasets = co.OrderedDict()
  427. for ks_ in (ks if by is not None else [()]):
  428. for x_ in (x if x is not None else [None]):
  429. for y_ in y:
  430. # hide x/y if there is only one field
  431. k_x = x_ if len(x or []) > 1 else ''
  432. k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else ''
  433. datasets[ks_ + (k_x, k_y)] = dataset(
  434. results,
  435. x_,
  436. y_,
  437. [(by_, k_) for by_, k_ in zip(by, ks_)]
  438. if by is not None else [])
  439. return datasets
  440. def main(csv_paths, *,
  441. by=None,
  442. x=None,
  443. y=None,
  444. define=[],
  445. xlim=(None,None),
  446. ylim=(None,None),
  447. width=None,
  448. height=17,
  449. cat=False,
  450. color=False,
  451. braille=False,
  452. colors=None,
  453. chars=None,
  454. line_chars=None,
  455. points=False,
  456. legend=None,
  457. keep_open=False,
  458. sleep=None,
  459. **args):
  460. # figure out what color should be
  461. if color == 'auto':
  462. color = sys.stdout.isatty()
  463. elif color == 'always':
  464. color = True
  465. else:
  466. color = False
  467. # allow shortened ranges
  468. if len(xlim) == 1:
  469. xlim = (0, xlim[0])
  470. if len(ylim) == 1:
  471. ylim = (0, ylim[0])
  472. # separate out renames
  473. renames = list(it.chain.from_iterable(
  474. ((k, v) for v in vs)
  475. for k, vs in it.chain(by or [], x or [], y or [])))
  476. if by is not None:
  477. by = [k for k, _ in by]
  478. if x is not None:
  479. x = [k for k, _ in x]
  480. if y is not None:
  481. y = [k for k, _ in y]
  482. def draw(f):
  483. def writeln(s=''):
  484. f.write(s)
  485. f.write('\n')
  486. f.writeln = writeln
  487. # first collect results from CSV files
  488. results = collect(csv_paths, renames)
  489. # then extract the requested datasets
  490. datasets_ = datasets(results, by, x, y, define)
  491. # what colors to use?
  492. if colors is not None:
  493. colors_ = colors
  494. else:
  495. colors_ = COLORS
  496. if chars is not None:
  497. chars_ = chars
  498. else:
  499. chars_ = [True]
  500. if line_chars is not None:
  501. line_chars_ = line_chars
  502. elif not points:
  503. line_chars_ = [True]
  504. else:
  505. line_chars_ = [False]
  506. # build legend?
  507. legend_width = 0
  508. if legend:
  509. legend_ = []
  510. for i, k in enumerate(datasets_.keys()):
  511. label = '%s%s' % (
  512. '%s ' % chars_[i % len(chars_)]
  513. if chars is not None
  514. else '%s ' % line_chars_[i % len(line_chars_)]
  515. if line_chars is not None
  516. else '',
  517. ','.join(k_ for k_ in k if k_))
  518. if label:
  519. legend_.append(label)
  520. legend_width = max(legend_width, len(label)+1)
  521. # find xlim/ylim
  522. xlim_ = (
  523. xlim[0] if xlim[0] is not None
  524. else min(it.chain([0], (k
  525. for r in datasets_.values()
  526. for k, v in r.items()
  527. if v is not None))),
  528. xlim[1] if xlim[1] is not None
  529. else max(it.chain([0], (k
  530. for r in datasets_.values()
  531. for k, v in r.items()
  532. if v is not None))))
  533. ylim_ = (
  534. ylim[0] if ylim[0] is not None
  535. else min(it.chain([0], (v
  536. for r in datasets_.values()
  537. for _, v in r.items()
  538. if v is not None))),
  539. ylim[1] if ylim[1] is not None
  540. else max(it.chain([0], (v
  541. for r in datasets_.values()
  542. for _, v in r.items()
  543. if v is not None))))
  544. # figure out our plot size
  545. if width is None:
  546. width_ = min(80, shutil.get_terminal_size((80, 17))[0])
  547. elif width:
  548. width_ = width
  549. else:
  550. width_ = shutil.get_terminal_size((80, 17))[0]
  551. # make space for units
  552. width_ -= 5
  553. # make space for legend
  554. if legend in {'left', 'right'} and legend_:
  555. width_ -= legend_width
  556. # limit a bit
  557. width_ = max(2*4, width_)
  558. if height:
  559. height_ = height
  560. else:
  561. height_ = shutil.get_terminal_size((80, 17))[1]
  562. # make space for shell prompt
  563. if not keep_open:
  564. height_ -= 1
  565. # make space for units
  566. height_ -= 1
  567. # make space for legend
  568. if legend in {'above', 'below'} and legend_:
  569. legend_cols = min(len(legend_), max(1, width_//legend_width))
  570. height_ -= (len(legend_)+legend_cols-1) // legend_cols
  571. # limit a bit
  572. height_ = max(2, height_)
  573. # create a plot and draw our coordinates
  574. plot = Plot(
  575. # scale if we're printing with dots or braille
  576. 2*width_ if line_chars is None and braille else width_,
  577. 4*height_ if line_chars is None and braille
  578. else 2*height_ if line_chars is None
  579. else height_,
  580. xlim=xlim_,
  581. ylim=ylim_,
  582. **args)
  583. for i, (k, dataset) in enumerate(datasets_.items()):
  584. plot.plot(
  585. sorted((x,y) for x,y in dataset.items()),
  586. color=colors_[i % len(colors_)],
  587. char=chars_[i % len(chars_)],
  588. line_char=line_chars_[i % len(line_chars_)])
  589. # draw legend=above?
  590. if legend == 'above' and legend_:
  591. for i in range(0, len(legend_), legend_cols):
  592. f.writeln('%4s %*s%s' % (
  593. '',
  594. max(width_ - sum(len(label)+1
  595. for label in legend_[i:i+legend_cols]),
  596. 0) // 2,
  597. '',
  598. ' '.join('%s%s%s' % (
  599. '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
  600. legend_[j],
  601. '\x1b[m' if color else '')
  602. for j in range(i, min(i+legend_cols, len(legend_))))))
  603. for row in range(height_):
  604. f.writeln('%s%4s %s%s' % (
  605. # draw legend=left?
  606. ('%s%-*s %s' % (
  607. '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
  608. legend_width-1,
  609. legend_[row] if row < len(legend_) else '',
  610. '\x1b[m' if color else ''))
  611. if legend == 'left' and legend_ else '',
  612. # draw plot
  613. si(ylim_[0], 4) if row == height_-1
  614. else si(ylim_[1], 4) if row == 0
  615. else '',
  616. plot.draw(row,
  617. braille=line_chars is None and braille,
  618. dots=line_chars is None and not braille,
  619. color=color,
  620. **args),
  621. # draw legend=right?
  622. (' %s%s%s' % (
  623. '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
  624. legend_[row] if row < len(legend_) else '',
  625. '\x1b[m' if color else ''))
  626. if legend == 'right' and legend_ else ''))
  627. f.writeln('%*s %-4s%*s%4s' % (
  628. 4 + (legend_width if legend == 'left' and legend_ else 0),
  629. '',
  630. si(xlim_[0], 4),
  631. width_ - 2*4,
  632. '',
  633. si(xlim_[1], 4)))
  634. # draw legend=below?
  635. if legend == 'below' and legend_:
  636. for i in range(0, len(legend_), legend_cols):
  637. f.writeln('%4s %*s%s' % (
  638. '',
  639. max(width_ - sum(len(label)+1
  640. for label in legend_[i:i+legend_cols]),
  641. 0) // 2,
  642. '',
  643. ' '.join('%s%s%s' % (
  644. '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
  645. legend_[j],
  646. '\x1b[m' if color else '')
  647. for j in range(i, min(i+legend_cols, len(legend_))))))
  648. if keep_open:
  649. try:
  650. while True:
  651. if cat:
  652. draw(sys.stdout)
  653. else:
  654. ring = LinesIO()
  655. draw(ring)
  656. ring.draw()
  657. # try to inotifywait
  658. if inotify_simple is not None:
  659. ptime = time.time()
  660. inotifywait(csv_paths)
  661. # sleep for a minimum amount of time, this helps issues
  662. # around rapidly updating files
  663. time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime)))
  664. else:
  665. time.sleep(sleep or 0.1)
  666. except KeyboardInterrupt:
  667. pass
  668. if cat:
  669. draw(sys.stdout)
  670. else:
  671. ring = LinesIO()
  672. draw(ring)
  673. ring.draw()
  674. sys.stdout.write('\n')
  675. else:
  676. draw(sys.stdout)
  677. if __name__ == "__main__":
  678. import sys
  679. import argparse
  680. parser = argparse.ArgumentParser(
  681. description="Plot CSV files in terminal.",
  682. allow_abbrev=False)
  683. parser.add_argument(
  684. 'csv_paths',
  685. nargs='*',
  686. help="Input *.csv files.")
  687. parser.add_argument(
  688. '-b', '--by',
  689. action='append',
  690. type=lambda x: (
  691. lambda k,v=None: (k, v.split(',') if v is not None else ())
  692. )(*x.split('=', 1)),
  693. help="Group by this field. Can rename fields with new_name=old_name.")
  694. parser.add_argument(
  695. '-x',
  696. action='append',
  697. type=lambda x: (
  698. lambda k,v=None: (k, v.split(',') if v is not None else ())
  699. )(*x.split('=', 1)),
  700. help="Field to use for the x-axis. Can rename fields with "
  701. "new_name=old_name.")
  702. parser.add_argument(
  703. '-y',
  704. action='append',
  705. type=lambda x: (
  706. lambda k,v=None: (k, v.split(',') if v is not None else ())
  707. )(*x.split('=', 1)),
  708. help="Field to use for the y-axis. Can rename fields with "
  709. "new_name=old_name.")
  710. parser.add_argument(
  711. '-D', '--define',
  712. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  713. action='append',
  714. help="Only include results where this field is this value. May include "
  715. "comma-separated options.")
  716. parser.add_argument(
  717. '--color',
  718. choices=['never', 'always', 'auto'],
  719. default='auto',
  720. help="When to use terminal colors. Defaults to 'auto'.")
  721. parser.add_argument(
  722. '-⣿', '--braille',
  723. action='store_true',
  724. help="Use 2x4 unicode braille characters. Note that braille characters "
  725. "sometimes suffer from inconsistent widths.")
  726. parser.add_argument(
  727. '--colors',
  728. type=lambda x: [x.strip() for x in x.split(',')],
  729. help="Colors to use.")
  730. parser.add_argument(
  731. '--chars',
  732. help="Characters to use for points.")
  733. parser.add_argument(
  734. '--line-chars',
  735. help="Characters to use for lines.")
  736. parser.add_argument(
  737. '-.', '--points',
  738. action='store_true',
  739. help="Only draw the data points.")
  740. parser.add_argument(
  741. '-W', '--width',
  742. nargs='?',
  743. type=lambda x: int(x, 0),
  744. const=0,
  745. help="Width in columns. 0 uses the terminal width. Defaults to "
  746. "min(terminal, 80).")
  747. parser.add_argument(
  748. '-H', '--height',
  749. nargs='?',
  750. type=lambda x: int(x, 0),
  751. const=0,
  752. help="Height in rows. 0 uses the terminal height. Defaults to 17.")
  753. parser.add_argument(
  754. '-z', '--cat',
  755. action='store_true',
  756. help="Pipe directly to stdout.")
  757. parser.add_argument(
  758. '-X', '--xlim',
  759. type=lambda x: tuple(
  760. dat(x) if x.strip() else None
  761. for x in x.split(',')),
  762. help="Range for the x-axis.")
  763. parser.add_argument(
  764. '-Y', '--ylim',
  765. type=lambda x: tuple(
  766. dat(x) if x.strip() else None
  767. for x in x.split(',')),
  768. help="Range for the y-axis.")
  769. parser.add_argument(
  770. '--xlog',
  771. action='store_true',
  772. help="Use a logarithmic x-axis.")
  773. parser.add_argument(
  774. '--ylog',
  775. action='store_true',
  776. help="Use a logarithmic y-axis.")
  777. parser.add_argument(
  778. '-l', '--legend',
  779. choices=['above', 'below', 'left', 'right'],
  780. help="Place a legend here.")
  781. parser.add_argument(
  782. '-k', '--keep-open',
  783. action='store_true',
  784. help="Continue to open and redraw the CSV files in a loop.")
  785. parser.add_argument(
  786. '-s', '--sleep',
  787. type=float,
  788. help="Time in seconds to sleep between redraws when running with -k. "
  789. "Defaults to 0.01.")
  790. sys.exit(main(**{k: v
  791. for k, v in vars(parser.parse_intermixed_args()).items()
  792. if v is not None}))