plot.py 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  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. y_ = None
  421. else:
  422. try:
  423. y_ = dat(r[y])
  424. except ValueError:
  425. y_ = None
  426. else:
  427. y_ = None
  428. if y_ is not None:
  429. dataset[x_] = y_ + dataset.get(x_, 0)
  430. else:
  431. dataset[x_] = y_ or dataset.get(x_, None)
  432. return dataset
  433. def datasets(results, by=None, x=None, y=None, define=[]):
  434. # filter results by matching defines
  435. results_ = []
  436. for r in results:
  437. if all(k in r and r[k] in vs for k, vs in define):
  438. results_.append(r)
  439. results = results_
  440. # if y not specified, try to guess from data
  441. if y is None:
  442. y = co.OrderedDict()
  443. for r in results:
  444. for k, v in r.items():
  445. if (by is None or k not in by) and v.strip():
  446. try:
  447. dat(v)
  448. y[k] = True
  449. except ValueError:
  450. y[k] = False
  451. y = list(k for k,v in y.items() if v)
  452. if by is not None:
  453. # find all 'by' values
  454. ks = set()
  455. for r in results:
  456. ks.add(tuple(r.get(k, '') for k in by))
  457. ks = sorted(ks)
  458. # collect all datasets
  459. datasets = co.OrderedDict()
  460. for ks_ in (ks if by is not None else [()]):
  461. for x_ in (x if x is not None else [None]):
  462. for y_ in y:
  463. # hide x/y if there is only one field
  464. k_x = x_ if len(x or []) > 1 else ''
  465. k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else ''
  466. datasets[ks_ + (k_x, k_y)] = dataset(
  467. results,
  468. x_,
  469. y_,
  470. [(by_, k_) for by_, k_ in zip(by, ks_)]
  471. if by is not None else [])
  472. return datasets
  473. def main(csv_paths, *,
  474. by=None,
  475. x=None,
  476. y=None,
  477. define=[],
  478. width=None,
  479. height=None,
  480. xlim=(None,None),
  481. ylim=(None,None),
  482. x2=False,
  483. y2=False,
  484. xunits='',
  485. yunits='',
  486. xlabel=None,
  487. ylabel=None,
  488. cat=False,
  489. color=False,
  490. braille=False,
  491. colors=None,
  492. chars=None,
  493. line_chars=None,
  494. points=False,
  495. points_and_lines=False,
  496. title=None,
  497. legend=None,
  498. keep_open=False,
  499. sleep=None,
  500. **args):
  501. # figure out what color should be
  502. if color == 'auto':
  503. color = sys.stdout.isatty()
  504. elif color == 'always':
  505. color = True
  506. else:
  507. color = False
  508. # allow shortened ranges
  509. if len(xlim) == 1:
  510. xlim = (0, xlim[0])
  511. if len(ylim) == 1:
  512. ylim = (0, ylim[0])
  513. # separate out renames
  514. renames = list(it.chain.from_iterable(
  515. ((k, v) for v in vs)
  516. for k, vs in it.chain(by or [], x or [], y or [])))
  517. if by is not None:
  518. by = [k for k, _ in by]
  519. if x is not None:
  520. x = [k for k, _ in x]
  521. if y is not None:
  522. y = [k for k, _ in y]
  523. # what colors to use?
  524. if colors is not None:
  525. colors_ = colors
  526. else:
  527. colors_ = COLORS
  528. if chars is not None:
  529. chars_ = chars
  530. elif points_and_lines:
  531. chars_ = CHARS_POINTS_AND_LINES
  532. else:
  533. chars_ = [True]
  534. if line_chars is not None:
  535. line_chars_ = line_chars
  536. elif points_and_lines or not points:
  537. line_chars_ = [True]
  538. else:
  539. line_chars_ = [False]
  540. # allow escape codes in labels/titles
  541. if title is not None:
  542. title = codecs.escape_decode(title.encode('utf8'))[0].decode('utf8')
  543. if xlabel is not None:
  544. xlabel = codecs.escape_decode(xlabel.encode('utf8'))[0].decode('utf8')
  545. if ylabel is not None:
  546. ylabel = codecs.escape_decode(ylabel.encode('utf8'))[0].decode('utf8')
  547. title = title.splitlines() if title is not None else []
  548. xlabel = xlabel.splitlines() if xlabel is not None else []
  549. ylabel = ylabel.splitlines() if ylabel is not None else []
  550. def draw(f):
  551. def writeln(s=''):
  552. f.write(s)
  553. f.write('\n')
  554. f.writeln = writeln
  555. # first collect results from CSV files
  556. results = collect(csv_paths, renames)
  557. # then extract the requested datasets
  558. datasets_ = datasets(results, by, x, y, define)
  559. # build legend?
  560. legend_width = 0
  561. if legend:
  562. legend_ = []
  563. for i, k in enumerate(datasets_.keys()):
  564. label = '%s%s' % (
  565. '%s ' % chars_[i % len(chars_)]
  566. if chars is not None
  567. else '%s ' % line_chars_[i % len(line_chars_)]
  568. if line_chars is not None
  569. else '',
  570. ','.join(k_ for k_ in k if k_))
  571. if label:
  572. legend_.append(label)
  573. legend_width = max(legend_width, len(label)+1)
  574. # find xlim/ylim
  575. xlim_ = (
  576. xlim[0] if xlim[0] is not None
  577. else min(it.chain([0], (k
  578. for r in datasets_.values()
  579. for k, v in r.items()
  580. if v is not None))),
  581. xlim[1] if xlim[1] is not None
  582. else max(it.chain([0], (k
  583. for r in datasets_.values()
  584. for k, v in r.items()
  585. if v is not None))))
  586. ylim_ = (
  587. ylim[0] if ylim[0] is not None
  588. else min(it.chain([0], (v
  589. for r in datasets_.values()
  590. for _, v in r.items()
  591. if v is not None))),
  592. ylim[1] if ylim[1] is not None
  593. else max(it.chain([0], (v
  594. for r in datasets_.values()
  595. for _, v in r.items()
  596. if v is not None))))
  597. # figure out our plot size
  598. if width is None:
  599. width_ = min(80, shutil.get_terminal_size((80, None))[0])
  600. elif width:
  601. width_ = width
  602. else:
  603. width_ = shutil.get_terminal_size((80, None))[0]
  604. # make space for units
  605. width_ -= (5 if y2 else 4)+1+len(yunits)
  606. # make space for label
  607. width_ -= len(ylabel)
  608. # make space for legend
  609. if legend in {'left', 'right'} and legend_:
  610. width_ -= legend_width
  611. # limit a bit
  612. width_ = max(2*((5 if x2 else 4)+len(xunits)), width_)
  613. if height is None:
  614. height_ = 17 + len(title) + len(xlabel)
  615. elif height:
  616. height_ = height
  617. else:
  618. height_ = shutil.get_terminal_size((None,
  619. 17 + len(title) + len(xlabel)))[1]
  620. # make space for shell prompt
  621. if not keep_open:
  622. height_ -= 1
  623. # make space for units
  624. height_ -= 1
  625. # make space for label
  626. height_ -= len(xlabel)
  627. # make space for title
  628. height_ -= len(title)
  629. # make space for legend
  630. if legend in {'above', 'below'} and legend_:
  631. legend_cols = min(len(legend_), max(1, width_//legend_width))
  632. height_ -= (len(legend_)+legend_cols-1) // legend_cols
  633. # limit a bit
  634. height_ = max(2, height_)
  635. # figure out margin for label/units/legend
  636. margin = (5 if y2 else 4) + len(yunits) + len(ylabel)
  637. if legend == 'left' and legend_:
  638. margin += legend_width
  639. # make it easier to transpose ylabel
  640. ylabel_ = [l.center(height_) for l in ylabel]
  641. # create a plot and draw our coordinates
  642. plot = Plot(
  643. # scale if we're printing with dots or braille
  644. 2*width_ if line_chars is None and braille else width_,
  645. 4*height_ if line_chars is None and braille
  646. else 2*height_ if line_chars is None
  647. else height_,
  648. xlim=xlim_,
  649. ylim=ylim_,
  650. **args)
  651. for i, (k, dataset) in enumerate(datasets_.items()):
  652. plot.plot(
  653. sorted((x,y) for x,y in dataset.items()),
  654. color=colors_[i % len(colors_)],
  655. char=chars_[i % len(chars_)],
  656. line_char=line_chars_[i % len(line_chars_)])
  657. # draw title?
  658. for line in title:
  659. f.writeln('%*s %s' % (margin, '', line.center(width_)))
  660. # draw legend=above?
  661. if legend == 'above' and legend_:
  662. for i in range(0, len(legend_), legend_cols):
  663. f.writeln('%*s %*s%s' % (
  664. margin,
  665. '',
  666. max(width_ - sum(len(label)+1
  667. for label in legend_[i:i+legend_cols]),
  668. 0) // 2,
  669. '',
  670. ' '.join('%s%s%s' % (
  671. '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
  672. legend_[j],
  673. '\x1b[m' if color else '')
  674. for j in range(i, min(i+legend_cols, len(legend_))))))
  675. for row in range(height_):
  676. f.writeln('%s%s%*s %s%s' % (
  677. # draw legend=left?
  678. ('%s%-*s %s' % (
  679. '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
  680. legend_width-1,
  681. legend_[row] if row < len(legend_) else '',
  682. '\x1b[m' if color else ''))
  683. if legend == 'left' and legend_ else '',
  684. # draw ylabel?
  685. ('%*s' % (
  686. len(ylabel),
  687. ''.join(l[row] for l in ylabel_))),
  688. # draw plot
  689. (5 if y2 else 4)+len(yunits),
  690. (si2 if y2 else si)(ylim_[0])+yunits if row == height_-1
  691. else (si2 if y2 else si)(ylim_[1])+yunits if row == 0
  692. else '',
  693. plot.draw(row,
  694. braille=line_chars is None and braille,
  695. dots=line_chars is None and not braille,
  696. color=color,
  697. **args),
  698. # draw legend=right?
  699. (' %s%s%s' % (
  700. '\x1b[%sm' % colors_[row % len(colors_)] if color else '',
  701. legend_[row] if row < len(legend_) else '',
  702. '\x1b[m' if color else ''))
  703. if legend == 'right' and legend_ else ''))
  704. f.writeln('%*s %-*s%*s%*s' % (
  705. margin,
  706. '',
  707. (5 if x2 else 4)+len(xunits),
  708. (si2 if x2 else si)(xlim_[0])+xunits,
  709. width_ - 2*((5 if x2 else 4)+len(xunits)),
  710. '',
  711. (5 if x2 else 4)+len(xunits),
  712. (si2 if x2 else si)(xlim_[1])+xunits))
  713. # draw xlabel?
  714. for line in xlabel:
  715. f.writeln('%*s %s' % (margin, '', line.center(width_)))
  716. # draw legend=below?
  717. if legend == 'below' and legend_:
  718. for i in range(0, len(legend_), legend_cols):
  719. f.writeln('%*s %*s%s' % (
  720. margin,
  721. '',
  722. max(width_ - sum(len(label)+1
  723. for label in legend_[i:i+legend_cols]),
  724. 0) // 2,
  725. '',
  726. ' '.join('%s%s%s' % (
  727. '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
  728. legend_[j],
  729. '\x1b[m' if color else '')
  730. for j in range(i, min(i+legend_cols, len(legend_))))))
  731. if keep_open:
  732. try:
  733. while True:
  734. if cat:
  735. draw(sys.stdout)
  736. else:
  737. ring = LinesIO()
  738. draw(ring)
  739. ring.draw()
  740. # try to inotifywait
  741. if inotify_simple is not None:
  742. ptime = time.time()
  743. inotifywait(csv_paths)
  744. # sleep for a minimum amount of time, this helps issues
  745. # around rapidly updating files
  746. time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime)))
  747. else:
  748. time.sleep(sleep or 0.1)
  749. except KeyboardInterrupt:
  750. pass
  751. if cat:
  752. draw(sys.stdout)
  753. else:
  754. ring = LinesIO()
  755. draw(ring)
  756. ring.draw()
  757. sys.stdout.write('\n')
  758. else:
  759. draw(sys.stdout)
  760. if __name__ == "__main__":
  761. import sys
  762. import argparse
  763. parser = argparse.ArgumentParser(
  764. description="Plot CSV files in terminal.",
  765. allow_abbrev=False)
  766. parser.add_argument(
  767. 'csv_paths',
  768. nargs='*',
  769. help="Input *.csv files.")
  770. parser.add_argument(
  771. '-b', '--by',
  772. action='append',
  773. type=lambda x: (
  774. lambda k,v=None: (k, v.split(',') if v is not None else ())
  775. )(*x.split('=', 1)),
  776. help="Group by this field. Can rename fields with new_name=old_name.")
  777. parser.add_argument(
  778. '-x',
  779. action='append',
  780. type=lambda x: (
  781. lambda k,v=None: (k, v.split(',') if v is not None else ())
  782. )(*x.split('=', 1)),
  783. help="Field to use for the x-axis. Can rename fields with "
  784. "new_name=old_name.")
  785. parser.add_argument(
  786. '-y',
  787. action='append',
  788. type=lambda x: (
  789. lambda k,v=None: (k, v.split(',') if v is not None else ())
  790. )(*x.split('=', 1)),
  791. help="Field to use for the y-axis. Can rename fields with "
  792. "new_name=old_name.")
  793. parser.add_argument(
  794. '-D', '--define',
  795. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  796. action='append',
  797. help="Only include results where this field is this value. May include "
  798. "comma-separated options.")
  799. parser.add_argument(
  800. '--color',
  801. choices=['never', 'always', 'auto'],
  802. default='auto',
  803. help="When to use terminal colors. Defaults to 'auto'.")
  804. parser.add_argument(
  805. '-⣿', '--braille',
  806. action='store_true',
  807. help="Use 2x4 unicode braille characters. Note that braille characters "
  808. "sometimes suffer from inconsistent widths.")
  809. parser.add_argument(
  810. '-.', '--points',
  811. action='store_true',
  812. help="Only draw data points.")
  813. parser.add_argument(
  814. '-!', '--points-and-lines',
  815. action='store_true',
  816. help="Draw data points and lines.")
  817. parser.add_argument(
  818. '--colors',
  819. type=lambda x: [x.strip() for x in x.split(',')],
  820. help="Comma-separated colors to use.")
  821. parser.add_argument(
  822. '--chars',
  823. help="Characters to use for points.")
  824. parser.add_argument(
  825. '--line-chars',
  826. help="Characters to use for lines.")
  827. parser.add_argument(
  828. '-W', '--width',
  829. nargs='?',
  830. type=lambda x: int(x, 0),
  831. const=0,
  832. help="Width in columns. 0 uses the terminal width. Defaults to "
  833. "min(terminal, 80).")
  834. parser.add_argument(
  835. '-H', '--height',
  836. nargs='?',
  837. type=lambda x: int(x, 0),
  838. const=0,
  839. help="Height in rows. 0 uses the terminal height. Defaults to 17.")
  840. parser.add_argument(
  841. '-z', '--cat',
  842. action='store_true',
  843. help="Pipe directly to stdout.")
  844. parser.add_argument(
  845. '-X', '--xlim',
  846. type=lambda x: tuple(
  847. dat(x) if x.strip() else None
  848. for x in x.split(',')),
  849. help="Range for the x-axis.")
  850. parser.add_argument(
  851. '-Y', '--ylim',
  852. type=lambda x: tuple(
  853. dat(x) if x.strip() else None
  854. for x in x.split(',')),
  855. help="Range for the y-axis.")
  856. parser.add_argument(
  857. '--xlog',
  858. action='store_true',
  859. help="Use a logarithmic x-axis.")
  860. parser.add_argument(
  861. '--ylog',
  862. action='store_true',
  863. help="Use a logarithmic y-axis.")
  864. parser.add_argument(
  865. '--x2',
  866. action='store_true',
  867. help="Use base-2 prefixes for the x-axis.")
  868. parser.add_argument(
  869. '--y2',
  870. action='store_true',
  871. help="Use base-2 prefixes for the y-axis.")
  872. parser.add_argument(
  873. '--xunits',
  874. help="Units for the x-axis.")
  875. parser.add_argument(
  876. '--yunits',
  877. help="Units for the y-axis.")
  878. parser.add_argument(
  879. '--xlabel',
  880. help="Add a label to the x-axis.")
  881. parser.add_argument(
  882. '--ylabel',
  883. help="Add a label to the y-axis.")
  884. parser.add_argument(
  885. '-t', '--title',
  886. help="Add a title.")
  887. parser.add_argument(
  888. '-l', '--legend',
  889. nargs='?',
  890. choices=['above', 'below', 'left', 'right'],
  891. const='right',
  892. help="Place a legend here.")
  893. parser.add_argument(
  894. '-k', '--keep-open',
  895. action='store_true',
  896. help="Continue to open and redraw the CSV files in a loop.")
  897. parser.add_argument(
  898. '-s', '--sleep',
  899. type=float,
  900. help="Time in seconds to sleep between redraws when running with -k. "
  901. "Defaults to 0.01.")
  902. sys.exit(main(**{k: v
  903. for k, v in vars(parser.parse_intermixed_args()).items()
  904. if v is not None}))