plot.py 25 KB

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