plot.py 30 KB

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