plot.py 25 KB

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