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 12import bisect 13import codecs 14import collections as co 15import csv 16import io 17import itertools as it 18import math as m 19import os 20import shlex 21import shutil 22import time 23 24try: 25 import inotify_simple 26except ModuleNotFoundError: 27 inotify_simple = None 28 29 30COLORS = [ 31 '1;34', # bold blue 32 '1;31', # bold red 33 '1;32', # bold green 34 '1;35', # bold purple 35 '1;33', # bold yellow 36 '1;36', # bold cyan 37 '34', # blue 38 '31', # red 39 '32', # green 40 '35', # purple 41 '33', # yellow 42 '36', # cyan 43] 44 45CHARS_DOTS = " .':" 46CHARS_BRAILLE = ( 47 '⠀⢀⡀⣀⠠⢠⡠⣠⠄⢄⡄⣄⠤⢤⡤⣤' '⠐⢐⡐⣐⠰⢰⡰⣰⠔⢔⡔⣔⠴⢴⡴⣴' 48 '⠂⢂⡂⣂⠢⢢⡢⣢⠆⢆⡆⣆⠦⢦⡦⣦' '⠒⢒⡒⣒⠲⢲⡲⣲⠖⢖⡖⣖⠶⢶⡶⣶' 49 '⠈⢈⡈⣈⠨⢨⡨⣨⠌⢌⡌⣌⠬⢬⡬⣬' '⠘⢘⡘⣘⠸⢸⡸⣸⠜⢜⡜⣜⠼⢼⡼⣼' 50 '⠊⢊⡊⣊⠪⢪⡪⣪⠎⢎⡎⣎⠮⢮⡮⣮' '⠚⢚⡚⣚⠺⢺⡺⣺⠞⢞⡞⣞⠾⢾⡾⣾' 51 '⠁⢁⡁⣁⠡⢡⡡⣡⠅⢅⡅⣅⠥⢥⡥⣥' '⠑⢑⡑⣑⠱⢱⡱⣱⠕⢕⡕⣕⠵⢵⡵⣵' 52 '⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷' 53 '⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽' 54 '⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿') 55CHARS_POINTS_AND_LINES = 'o' 56 57SI_PREFIXES = { 58 18: 'E', 59 15: 'P', 60 12: 'T', 61 9: 'G', 62 6: 'M', 63 3: 'K', 64 0: '', 65 -3: 'm', 66 -6: 'u', 67 -9: 'n', 68 -12: 'p', 69 -15: 'f', 70 -18: 'a', 71} 72 73SI2_PREFIXES = { 74 60: 'Ei', 75 50: 'Pi', 76 40: 'Ti', 77 30: 'Gi', 78 20: 'Mi', 79 10: 'Ki', 80 0: '', 81 -10: 'mi', 82 -20: 'ui', 83 -30: 'ni', 84 -40: 'pi', 85 -50: 'fi', 86 -60: 'ai', 87} 88 89 90# format a number to a strict character width using SI prefixes 91def si(x, w=4): 92 if x == 0: 93 return '0' 94 # figure out prefix and scale 95 # 96 # note we adjust this so that 100K = .1M, which has more info 97 # per character 98 p = 3*int(m.log(abs(x)*10, 10**3)) 99 p = min(18, max(-18, p)) 100 # format with enough digits 101 s = '%.*f' % (w, abs(x) / (10.0**p)) 102 s = s.lstrip('0') 103 # truncate but only digits that follow the dot 104 if '.' in s: 105 s = s[:max(s.find('.'), w-(2 if x < 0 else 1))] 106 s = s.rstrip('0') 107 s = s.rstrip('.') 108 return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p]) 109 110def si2(x, w=5): 111 if x == 0: 112 return '0' 113 # figure out prefix and scale 114 # 115 # note we adjust this so that 128Ki = .1Mi, which has more info 116 # per character 117 p = 10*int(m.log(abs(x)*10, 2**10)) 118 p = min(30, max(-30, p)) 119 # format with enough digits 120 s = '%.*f' % (w, abs(x) / (2.0**p)) 121 s = s.lstrip('0') 122 # truncate but only digits that follow the dot 123 if '.' in s: 124 s = s[:max(s.find('.'), w-(3 if x < 0 else 2))] 125 s = s.rstrip('0') 126 s = s.rstrip('.') 127 return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p]) 128 129# parse escape strings 130def escape(s): 131 return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8') 132 133def openio(path, mode='r', buffering=-1): 134 # allow '-' for stdin/stdout 135 if path == '-': 136 if mode == 'r': 137 return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering) 138 else: 139 return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering) 140 else: 141 return open(path, mode, buffering) 142 143def inotifywait(paths): 144 # wait for interesting events 145 inotify = inotify_simple.INotify() 146 flags = (inotify_simple.flags.ATTRIB 147 | inotify_simple.flags.CREATE 148 | inotify_simple.flags.DELETE 149 | inotify_simple.flags.DELETE_SELF 150 | inotify_simple.flags.MODIFY 151 | inotify_simple.flags.MOVED_FROM 152 | inotify_simple.flags.MOVED_TO 153 | inotify_simple.flags.MOVE_SELF) 154 155 # recurse into directories 156 for path in paths: 157 if os.path.isdir(path): 158 for dir, _, files in os.walk(path): 159 inotify.add_watch(dir, flags) 160 for f in files: 161 inotify.add_watch(os.path.join(dir, f), flags) 162 else: 163 inotify.add_watch(path, flags) 164 165 # wait for event 166 inotify.read() 167 168class LinesIO: 169 def __init__(self, maxlen=None): 170 self.maxlen = maxlen 171 self.lines = co.deque(maxlen=maxlen) 172 self.tail = io.StringIO() 173 174 # trigger automatic sizing 175 if maxlen == 0: 176 self.resize(0) 177 178 def write(self, s): 179 # note using split here ensures the trailing string has no newline 180 lines = s.split('\n') 181 182 if len(lines) > 1 and self.tail.getvalue(): 183 self.tail.write(lines[0]) 184 lines[0] = self.tail.getvalue() 185 self.tail = io.StringIO() 186 187 self.lines.extend(lines[:-1]) 188 189 if lines[-1]: 190 self.tail.write(lines[-1]) 191 192 def resize(self, maxlen): 193 self.maxlen = maxlen 194 if maxlen == 0: 195 maxlen = shutil.get_terminal_size((80, 5))[1] 196 if maxlen != self.lines.maxlen: 197 self.lines = co.deque(self.lines, maxlen=maxlen) 198 199 canvas_lines = 1 200 def draw(self): 201 # did terminal size change? 202 if self.maxlen == 0: 203 self.resize(0) 204 205 # first thing first, give ourself a canvas 206 while LinesIO.canvas_lines < len(self.lines): 207 sys.stdout.write('\n') 208 LinesIO.canvas_lines += 1 209 210 # clear the bottom of the canvas if we shrink 211 shrink = LinesIO.canvas_lines - len(self.lines) 212 if shrink > 0: 213 for i in range(shrink): 214 sys.stdout.write('\r') 215 if shrink-1-i > 0: 216 sys.stdout.write('\x1b[%dA' % (shrink-1-i)) 217 sys.stdout.write('\x1b[K') 218 if shrink-1-i > 0: 219 sys.stdout.write('\x1b[%dB' % (shrink-1-i)) 220 sys.stdout.write('\x1b[%dA' % shrink) 221 LinesIO.canvas_lines = len(self.lines) 222 223 for i, line in enumerate(self.lines): 224 # move cursor, clear line, disable/reenable line wrapping 225 sys.stdout.write('\r') 226 if len(self.lines)-1-i > 0: 227 sys.stdout.write('\x1b[%dA' % (len(self.lines)-1-i)) 228 sys.stdout.write('\x1b[K') 229 sys.stdout.write('\x1b[?7l') 230 sys.stdout.write(line) 231 sys.stdout.write('\x1b[?7h') 232 if len(self.lines)-1-i > 0: 233 sys.stdout.write('\x1b[%dB' % (len(self.lines)-1-i)) 234 sys.stdout.flush() 235 236 237# parse different data representations 238def dat(x): 239 # allow the first part of an a/b fraction 240 if '/' in x: 241 x, _ = x.split('/', 1) 242 243 # first try as int 244 try: 245 return int(x, 0) 246 except ValueError: 247 pass 248 249 # then try as float 250 try: 251 return float(x) 252 # just don't allow infinity or nan 253 if m.isinf(x) or m.isnan(x): 254 raise ValueError("invalid dat %r" % x) 255 except ValueError: 256 pass 257 258 # else give up 259 raise ValueError("invalid dat %r" % x) 260 261 262# a hack log that preserves sign, with a linear region between -1 and 1 263def symlog(x): 264 if x > 1: 265 return m.log(x)+1 266 elif x < -1: 267 return -m.log(-x)-1 268 else: 269 return x 270 271class Plot: 272 def __init__(self, width, height, *, 273 xlim=None, 274 ylim=None, 275 xlog=False, 276 ylog=False, 277 braille=False, 278 dots=False): 279 # scale if we're printing with dots or braille 280 self.width = 2*width if braille else width 281 self.height = (4*height if braille 282 else 2*height if dots 283 else height) 284 285 self.xlim = xlim or (0, width) 286 self.ylim = ylim or (0, height) 287 self.xlog = xlog 288 self.ylog = ylog 289 self.braille = braille 290 self.dots = dots 291 292 self.grid = [('',False)]*(self.width*self.height) 293 294 def scale(self, x, y): 295 # scale and clamp 296 try: 297 if self.xlog: 298 x = int(self.width * ( 299 (symlog(x)-symlog(self.xlim[0])) 300 / (symlog(self.xlim[1])-symlog(self.xlim[0])))) 301 else: 302 x = int(self.width * ( 303 (x-self.xlim[0]) 304 / (self.xlim[1]-self.xlim[0]))) 305 if self.ylog: 306 y = int(self.height * ( 307 (symlog(y)-symlog(self.ylim[0])) 308 / (symlog(self.ylim[1])-symlog(self.ylim[0])))) 309 else: 310 y = int(self.height * ( 311 (y-self.ylim[0]) 312 / (self.ylim[1]-self.ylim[0]))) 313 except ZeroDivisionError: 314 x = 0 315 y = 0 316 return x, y 317 318 def point(self, x, y, *, 319 color=COLORS[0], 320 char=True): 321 # scale 322 x, y = self.scale(x, y) 323 324 # ignore out of bounds points 325 if x >= 0 and x < self.width and y >= 0 and y < self.height: 326 self.grid[x + y*self.width] = (color, char) 327 328 def line(self, x1, y1, x2, y2, *, 329 color=COLORS[0], 330 char=True): 331 # scale 332 x1, y1 = self.scale(x1, y1) 333 x2, y2 = self.scale(x2, y2) 334 335 # incremental error line algorithm 336 ex = abs(x2 - x1) 337 ey = -abs(y2 - y1) 338 dx = +1 if x1 < x2 else -1 339 dy = +1 if y1 < y2 else -1 340 e = ex + ey 341 342 while True: 343 if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height: 344 self.grid[x1 + y1*self.width] = (color, char) 345 e2 = 2*e 346 347 if x1 == x2 and y1 == y2: 348 break 349 350 if e2 > ey: 351 e += ey 352 x1 += dx 353 354 if x1 == x2 and y1 == y2: 355 break 356 357 if e2 < ex: 358 e += ex 359 y1 += dy 360 361 if x2 >= 0 and x2 < self.width and y2 >= 0 and y2 < self.height: 362 self.grid[x2 + y2*self.width] = (color, char) 363 364 def plot(self, coords, *, 365 color=COLORS[0], 366 char=True, 367 line_char=True): 368 # draw lines 369 if line_char: 370 for (x1, y1), (x2, y2) in zip(coords, coords[1:]): 371 if y1 is not None and y2 is not None: 372 self.line(x1, y1, x2, y2, 373 color=color, 374 char=line_char) 375 376 # draw points 377 if char and (not line_char or char is not True): 378 for x, y in coords: 379 if y is not None: 380 self.point(x, y, 381 color=color, 382 char=char) 383 384 def draw(self, row, *, 385 color=False): 386 # scale if needed 387 if self.braille: 388 xscale, yscale = 2, 4 389 elif self.dots: 390 xscale, yscale = 1, 2 391 else: 392 xscale, yscale = 1, 1 393 394 y = self.height//yscale-1 - row 395 row_ = [] 396 for x in range(self.width//xscale): 397 best_f = '' 398 best_c = False 399 400 # encode into a byte 401 b = 0 402 for i in range(xscale*yscale): 403 f, c = self.grid[x*xscale+(xscale-1-(i%xscale)) 404 + (y*yscale+(i//xscale))*self.width] 405 if c: 406 b |= 1 << i 407 408 if f: 409 best_f = f 410 if c and c is not True: 411 best_c = c 412 413 # use byte to lookup character 414 if b: 415 if best_c: 416 c = best_c 417 elif self.braille: 418 c = CHARS_BRAILLE[b] 419 else: 420 c = CHARS_DOTS[b] 421 else: 422 c = ' ' 423 424 # color? 425 if b and color and best_f: 426 c = '\x1b[%sm%s\x1b[m' % (best_f, c) 427 428 # draw axis in blank spaces 429 if not b: 430 if x == 0 and y == 0: 431 c = '+' 432 elif x == 0 and y == self.height//yscale-1: 433 c = '^' 434 elif x == self.width//xscale-1 and y == 0: 435 c = '>' 436 elif x == 0: 437 c = '|' 438 elif y == 0: 439 c = '-' 440 441 row_.append(c) 442 443 return ''.join(row_) 444 445 446def collect(csv_paths, renames=[]): 447 # collect results from CSV files 448 results = [] 449 for path in csv_paths: 450 try: 451 with openio(path) as f: 452 reader = csv.DictReader(f, restval='') 453 for r in reader: 454 results.append(r) 455 except FileNotFoundError: 456 pass 457 458 if renames: 459 for r in results: 460 # make a copy so renames can overlap 461 r_ = {} 462 for new_k, old_k in renames: 463 if old_k in r: 464 r_[new_k] = r[old_k] 465 r.update(r_) 466 467 return results 468 469def dataset(results, x=None, y=None, define=[]): 470 # organize by 'by', x, and y 471 dataset = {} 472 i = 0 473 for r in results: 474 # filter results by matching defines 475 if not all(k in r and r[k] in vs for k, vs in define): 476 continue 477 478 # find xs 479 if x is not None: 480 if x not in r: 481 continue 482 try: 483 x_ = dat(r[x]) 484 except ValueError: 485 continue 486 else: 487 x_ = i 488 i += 1 489 490 # find ys 491 if y is not None: 492 if y not in r: 493 continue 494 try: 495 y_ = dat(r[y]) 496 except ValueError: 497 continue 498 else: 499 y_ = None 500 501 if y_ is not None: 502 dataset[x_] = y_ + dataset.get(x_, 0) 503 else: 504 dataset[x_] = y_ or dataset.get(x_, None) 505 506 return dataset 507 508def datasets(results, by=None, x=None, y=None, define=[]): 509 # filter results by matching defines 510 results_ = [] 511 for r in results: 512 if all(k in r and r[k] in vs for k, vs in define): 513 results_.append(r) 514 results = results_ 515 516 # if y not specified, try to guess from data 517 if y is None: 518 y = co.OrderedDict() 519 for r in results: 520 for k, v in r.items(): 521 if (by is None or k not in by) and v.strip(): 522 try: 523 dat(v) 524 y[k] = True 525 except ValueError: 526 y[k] = False 527 y = list(k for k,v in y.items() if v) 528 529 if by is not None: 530 # find all 'by' values 531 ks = set() 532 for r in results: 533 ks.add(tuple(r.get(k, '') for k in by)) 534 ks = sorted(ks) 535 536 # collect all datasets 537 datasets = co.OrderedDict() 538 for ks_ in (ks if by is not None else [()]): 539 for x_ in (x if x is not None else [None]): 540 for y_ in y: 541 # hide x/y if there is only one field 542 k_x = x_ if len(x or []) > 1 else '' 543 k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else '' 544 545 datasets[ks_ + (k_x, k_y)] = dataset( 546 results, 547 x_, 548 y_, 549 [(by_, {k_}) for by_, k_ in zip(by, ks_)] 550 if by is not None else []) 551 552 return datasets 553 554 555# some classes for organizing subplots into a grid 556class Subplot: 557 def __init__(self, **args): 558 self.x = 0 559 self.y = 0 560 self.xspan = 1 561 self.yspan = 1 562 self.args = args 563 564class Grid: 565 def __init__(self, subplot, width=1.0, height=1.0): 566 self.xweights = [width] 567 self.yweights = [height] 568 self.map = {(0,0): subplot} 569 self.subplots = [subplot] 570 571 def __repr__(self): 572 return 'Grid(%r, %r)' % (self.xweights, self.yweights) 573 574 @property 575 def width(self): 576 return len(self.xweights) 577 578 @property 579 def height(self): 580 return len(self.yweights) 581 582 def __iter__(self): 583 return iter(self.subplots) 584 585 def __getitem__(self, i): 586 x, y = i 587 if x < 0: 588 x += len(self.xweights) 589 if y < 0: 590 y += len(self.yweights) 591 592 return self.map[(x,y)] 593 594 def merge(self, other, dir): 595 if dir in ['above', 'below']: 596 # first scale the two grids so they line up 597 self_xweights = self.xweights 598 other_xweights = other.xweights 599 self_w = sum(self_xweights) 600 other_w = sum(other_xweights) 601 ratio = self_w / other_w 602 other_xweights = [s*ratio for s in other_xweights] 603 604 # now interleave xweights as needed 605 new_xweights = [] 606 self_map = {} 607 other_map = {} 608 self_i = 0 609 other_i = 0 610 self_xweight = (self_xweights[self_i] 611 if self_i < len(self_xweights) else m.inf) 612 other_xweight = (other_xweights[other_i] 613 if other_i < len(other_xweights) else m.inf) 614 while self_i < len(self_xweights) and other_i < len(other_xweights): 615 if other_xweight - self_xweight > 0.0000001: 616 new_xweights.append(self_xweight) 617 other_xweight -= self_xweight 618 619 new_i = len(new_xweights)-1 620 for j in range(len(self.yweights)): 621 self_map[(new_i, j)] = self.map[(self_i, j)] 622 for j in range(len(other.yweights)): 623 other_map[(new_i, j)] = other.map[(other_i, j)] 624 for s in other.subplots: 625 if s.x+s.xspan-1 == new_i: 626 s.xspan += 1 627 elif s.x > new_i: 628 s.x += 1 629 630 self_i += 1 631 self_xweight = (self_xweights[self_i] 632 if self_i < len(self_xweights) else m.inf) 633 elif self_xweight - other_xweight > 0.0000001: 634 new_xweights.append(other_xweight) 635 self_xweight -= other_xweight 636 637 new_i = len(new_xweights)-1 638 for j in range(len(other.yweights)): 639 other_map[(new_i, j)] = other.map[(other_i, j)] 640 for j in range(len(self.yweights)): 641 self_map[(new_i, j)] = self.map[(self_i, j)] 642 for s in self.subplots: 643 if s.x+s.xspan-1 == new_i: 644 s.xspan += 1 645 elif s.x > new_i: 646 s.x += 1 647 648 other_i += 1 649 other_xweight = (other_xweights[other_i] 650 if other_i < len(other_xweights) else m.inf) 651 else: 652 new_xweights.append(self_xweight) 653 654 new_i = len(new_xweights)-1 655 for j in range(len(self.yweights)): 656 self_map[(new_i, j)] = self.map[(self_i, j)] 657 for j in range(len(other.yweights)): 658 other_map[(new_i, j)] = other.map[(other_i, j)] 659 660 self_i += 1 661 self_xweight = (self_xweights[self_i] 662 if self_i < len(self_xweights) else m.inf) 663 other_i += 1 664 other_xweight = (other_xweights[other_i] 665 if other_i < len(other_xweights) else m.inf) 666 667 # squish so ratios are preserved 668 self_h = sum(self.yweights) 669 other_h = sum(other.yweights) 670 ratio = (self_h-other_h) / self_h 671 self_yweights = [s*ratio for s in self.yweights] 672 673 # finally concatenate the two grids 674 if dir == 'above': 675 for s in other.subplots: 676 s.y += len(self_yweights) 677 self.subplots.extend(other.subplots) 678 679 self.xweights = new_xweights 680 self.yweights = self_yweights + other.yweights 681 self.map = self_map | {(x, y+len(self_yweights)): s 682 for (x, y), s in other_map.items()} 683 else: 684 for s in self.subplots: 685 s.y += len(other.yweights) 686 self.subplots.extend(other.subplots) 687 688 self.xweights = new_xweights 689 self.yweights = other.yweights + self_yweights 690 self.map = other_map | {(x, y+len(other.yweights)): s 691 for (x, y), s in self_map.items()} 692 693 if dir in ['right', 'left']: 694 # first scale the two grids so they line up 695 self_yweights = self.yweights 696 other_yweights = other.yweights 697 self_h = sum(self_yweights) 698 other_h = sum(other_yweights) 699 ratio = self_h / other_h 700 other_yweights = [s*ratio for s in other_yweights] 701 702 # now interleave yweights as needed 703 new_yweights = [] 704 self_map = {} 705 other_map = {} 706 self_i = 0 707 other_i = 0 708 self_yweight = (self_yweights[self_i] 709 if self_i < len(self_yweights) else m.inf) 710 other_yweight = (other_yweights[other_i] 711 if other_i < len(other_yweights) else m.inf) 712 while self_i < len(self_yweights) and other_i < len(other_yweights): 713 if other_yweight - self_yweight > 0.0000001: 714 new_yweights.append(self_yweight) 715 other_yweight -= self_yweight 716 717 new_i = len(new_yweights)-1 718 for j in range(len(self.xweights)): 719 self_map[(j, new_i)] = self.map[(j, self_i)] 720 for j in range(len(other.xweights)): 721 other_map[(j, new_i)] = other.map[(j, other_i)] 722 for s in other.subplots: 723 if s.y+s.yspan-1 == new_i: 724 s.yspan += 1 725 elif s.y > new_i: 726 s.y += 1 727 728 self_i += 1 729 self_yweight = (self_yweights[self_i] 730 if self_i < len(self_yweights) else m.inf) 731 elif self_yweight - other_yweight > 0.0000001: 732 new_yweights.append(other_yweight) 733 self_yweight -= other_yweight 734 735 new_i = len(new_yweights)-1 736 for j in range(len(other.xweights)): 737 other_map[(j, new_i)] = other.map[(j, other_i)] 738 for j in range(len(self.xweights)): 739 self_map[(j, new_i)] = self.map[(j, self_i)] 740 for s in self.subplots: 741 if s.y+s.yspan-1 == new_i: 742 s.yspan += 1 743 elif s.y > new_i: 744 s.y += 1 745 746 other_i += 1 747 other_yweight = (other_yweights[other_i] 748 if other_i < len(other_yweights) else m.inf) 749 else: 750 new_yweights.append(self_yweight) 751 752 new_i = len(new_yweights)-1 753 for j in range(len(self.xweights)): 754 self_map[(j, new_i)] = self.map[(j, self_i)] 755 for j in range(len(other.xweights)): 756 other_map[(j, new_i)] = other.map[(j, other_i)] 757 758 self_i += 1 759 self_yweight = (self_yweights[self_i] 760 if self_i < len(self_yweights) else m.inf) 761 other_i += 1 762 other_yweight = (other_yweights[other_i] 763 if other_i < len(other_yweights) else m.inf) 764 765 # squish so ratios are preserved 766 self_w = sum(self.xweights) 767 other_w = sum(other.xweights) 768 ratio = (self_w-other_w) / self_w 769 self_xweights = [s*ratio for s in self.xweights] 770 771 # finally concatenate the two grids 772 if dir == 'right': 773 for s in other.subplots: 774 s.x += len(self_xweights) 775 self.subplots.extend(other.subplots) 776 777 self.xweights = self_xweights + other.xweights 778 self.yweights = new_yweights 779 self.map = self_map | {(x+len(self_xweights), y): s 780 for (x, y), s in other_map.items()} 781 else: 782 for s in self.subplots: 783 s.x += len(other.xweights) 784 self.subplots.extend(other.subplots) 785 786 self.xweights = other.xweights + self_xweights 787 self.yweights = new_yweights 788 self.map = other_map | {(x+len(other.xweights), y): s 789 for (x, y), s in self_map.items()} 790 791 792 def scale(self, width, height): 793 self.xweights = [s*width for s in self.xweights] 794 self.yweights = [s*height for s in self.yweights] 795 796 @classmethod 797 def fromargs(cls, width=1.0, height=1.0, *, 798 subplots=[], 799 **args): 800 grid = cls(Subplot(**args)) 801 802 for dir, subargs in subplots: 803 subgrid = cls.fromargs( 804 width=subargs.pop('width', 805 0.5 if dir in ['right', 'left'] else width), 806 height=subargs.pop('height', 807 0.5 if dir in ['above', 'below'] else height), 808 **subargs) 809 grid.merge(subgrid, dir) 810 811 grid.scale(width, height) 812 return grid 813 814 815def main(csv_paths, *, 816 by=None, 817 x=None, 818 y=None, 819 define=[], 820 color=False, 821 braille=False, 822 colors=None, 823 chars=None, 824 line_chars=None, 825 points=False, 826 points_and_lines=False, 827 width=None, 828 height=None, 829 xlim=(None,None), 830 ylim=(None,None), 831 xlog=False, 832 ylog=False, 833 x2=False, 834 y2=False, 835 xunits='', 836 yunits='', 837 xlabel=None, 838 ylabel=None, 839 xticklabels=None, 840 yticklabels=None, 841 title=None, 842 legend_right=False, 843 legend_above=False, 844 legend_below=False, 845 subplot={}, 846 subplots=[], 847 cat=False, 848 keep_open=False, 849 sleep=None, 850 **args): 851 # figure out what color should be 852 if color == 'auto': 853 color = sys.stdout.isatty() 854 elif color == 'always': 855 color = True 856 else: 857 color = False 858 859 # what colors to use? 860 if colors is not None: 861 colors_ = colors 862 else: 863 colors_ = COLORS 864 865 if chars is not None: 866 chars_ = chars 867 elif points_and_lines: 868 chars_ = CHARS_POINTS_AND_LINES 869 else: 870 chars_ = [True] 871 872 if line_chars is not None: 873 line_chars_ = line_chars 874 elif points_and_lines or not points: 875 line_chars_ = [True] 876 else: 877 line_chars_ = [False] 878 879 # allow escape codes in labels/titles 880 title = escape(title).splitlines() if title is not None else [] 881 xlabel = escape(xlabel).splitlines() if xlabel is not None else [] 882 ylabel = escape(ylabel).splitlines() if ylabel is not None else [] 883 884 # separate out renames 885 renames = list(it.chain.from_iterable( 886 ((k, v) for v in vs) 887 for k, vs in it.chain(by or [], x or [], y or []))) 888 if by is not None: 889 by = [k for k, _ in by] 890 if x is not None: 891 x = [k for k, _ in x] 892 if y is not None: 893 y = [k for k, _ in y] 894 895 # create a grid of subplots 896 grid = Grid.fromargs( 897 subplots=subplots + subplot.pop('subplots', []), 898 **subplot) 899 900 for s in grid: 901 # allow subplot params to override global params 902 x2_ = s.args.get('x2', False) or x2 903 y2_ = s.args.get('y2', False) or y2 904 xunits_ = s.args.get('xunits', xunits) 905 yunits_ = s.args.get('yunits', yunits) 906 xticklabels_ = s.args.get('xticklabels', xticklabels) 907 yticklabels_ = s.args.get('yticklabels', yticklabels) 908 909 # label/titles are handled a bit differently in subplots 910 subtitle = s.args.get('title') 911 xsublabel = s.args.get('xlabel') 912 ysublabel = s.args.get('ylabel') 913 914 # allow escape codes in sublabels/subtitles 915 subtitle = (escape(subtitle).splitlines() 916 if subtitle is not None else []) 917 xsublabel = (escape(xsublabel).splitlines() 918 if xsublabel is not None else []) 919 ysublabel = (escape(ysublabel).splitlines() 920 if ysublabel is not None else []) 921 922 # don't allow >2 ticklabels and render single ticklabels only once 923 if xticklabels_ is not None: 924 if len(xticklabels_) == 1: 925 xticklabels_ = ["", xticklabels_[0]] 926 elif len(xticklabels_) > 2: 927 xticklabels_ = [xticklabels_[0], xticklabels_[-1]] 928 if yticklabels_ is not None: 929 if len(yticklabels_) == 1: 930 yticklabels_ = ["", yticklabels_[0]] 931 elif len(yticklabels_) > 2: 932 yticklabels_ = [yticklabels_[0], yticklabels_[-1]] 933 934 s.x2 = x2_ 935 s.y2 = y2_ 936 s.xunits = xunits_ 937 s.yunits = yunits_ 938 s.xticklabels = xticklabels_ 939 s.yticklabels = yticklabels_ 940 s.title = subtitle 941 s.xlabel = xsublabel 942 s.ylabel = ysublabel 943 944 # preprocess margins so they can be shared 945 for s in grid: 946 s.xmargin = ( 947 len(s.ylabel) + (1 if s.ylabel else 0) # fit ysublabel 948 + (1 if s.x > 0 else 0), # space between 949 ((5 if s.y2 else 4) + len(s.yunits) # fit yticklabels 950 if s.yticklabels is None 951 else max((len(t) for t in s.yticklabels), default=0)) 952 + (1 if s.yticklabels != [] else 0), 953 ) 954 s.ymargin = ( 955 len(s.xlabel), # fit xsublabel 956 1 if s.xticklabels != [] else 0, # fit xticklabels 957 len(s.title), # fit subtitle 958 ) 959 960 for s in grid: 961 # share margins so everything aligns nicely 962 s.xmargin = ( 963 max(s_.xmargin[0] for s_ in grid if s_.x == s.x), 964 max(s_.xmargin[1] for s_ in grid if s_.x == s.x), 965 ) 966 s.ymargin = ( 967 max(s_.ymargin[0] for s_ in grid if s_.y == s.y), 968 max(s_.ymargin[1] for s_ in grid if s_.y == s.y), 969 max(s_.ymargin[-1] for s_ in grid if s_.y+s_.yspan == s.y+s.yspan), 970 ) 971 972 973 def draw(f): 974 def writeln(s=''): 975 f.write(s) 976 f.write('\n') 977 f.writeln = writeln 978 979 # first collect results from CSV files 980 results = collect(csv_paths, renames) 981 982 # then extract the requested datasets 983 datasets_ = datasets(results, by, x, y, define) 984 985 # figure out colors/chars here so that subplot defines 986 # don't change them later, that'd be bad 987 datacolors_ = { 988 name: colors_[i % len(colors_)] 989 for i, name in enumerate(datasets_.keys())} 990 datachars_ = { 991 name: chars_[i % len(chars_)] 992 for i, name in enumerate(datasets_.keys())} 993 dataline_chars_ = { 994 name: line_chars_[i % len(line_chars_)] 995 for i, name in enumerate(datasets_.keys())} 996 997 # build legend? 998 legend_width = 0 999 if legend_right or legend_above or legend_below: 1000 legend_ = [] 1001 for i, k in enumerate(datasets_.keys()): 1002 label = '%s%s' % ( 1003 '%s ' % chars_[i % len(chars_)] 1004 if chars is not None 1005 else '%s ' % line_chars_[i % len(line_chars_)] 1006 if line_chars is not None 1007 else '', 1008 ','.join(k_ for k_ in k if k_)) 1009 1010 if label: 1011 legend_.append(label) 1012 legend_width = max(legend_width, len(label)+1) 1013 1014 # figure out our canvas size 1015 if width is None: 1016 width_ = min(80, shutil.get_terminal_size((80, None))[0]) 1017 elif width: 1018 width_ = width 1019 else: 1020 width_ = shutil.get_terminal_size((80, None))[0] 1021 1022 if height is None: 1023 height_ = 17 + len(title) + len(xlabel) 1024 elif height: 1025 height_ = height 1026 else: 1027 height_ = shutil.get_terminal_size((None, 1028 17 + len(title) + len(xlabel)))[1] 1029 # make space for shell prompt 1030 if not keep_open: 1031 height_ -= 1 1032 1033 # carve out space for the xlabel 1034 height_ -= len(xlabel) 1035 # carve out space for the ylabel 1036 width_ -= len(ylabel) + (1 if ylabel else 0) 1037 # carve out space for title 1038 height_ -= len(title) 1039 1040 # carve out space for the legend 1041 if legend_right and legend_: 1042 width_ -= legend_width 1043 if legend_above and legend_: 1044 legend_cols = len(legend_) 1045 while True: 1046 legend_widths = [ 1047 max(len(l) for l in legend_[i::legend_cols]) 1048 for i in range(legend_cols)] 1049 if (legend_cols <= 1 1050 or sum(legend_widths)+2*(legend_cols-1) 1051 + max(sum(s.xmargin[:2]) for s in grid if s.x == 0) 1052 <= width_): 1053 break 1054 legend_cols -= 1 1055 height_ -= (len(legend_)+legend_cols-1) // legend_cols 1056 if legend_below and legend_: 1057 legend_cols = len(legend_) 1058 while True: 1059 legend_widths = [ 1060 max(len(l) for l in legend_[i::legend_cols]) 1061 for i in range(legend_cols)] 1062 if (legend_cols <= 1 1063 or sum(legend_widths)+2*(legend_cols-1) 1064 + max(sum(s.xmargin[:2]) for s in grid if s.x == 0) 1065 <= width_): 1066 break 1067 legend_cols -= 1 1068 height_ -= (len(legend_)+legend_cols-1) // legend_cols 1069 1070 # figure out the grid dimensions 1071 # 1072 # note we floor to give the dimension tweaks the best chance of not 1073 # exceeding the requested dimensions, this means we usually are less 1074 # than the requested dimensions by quite a bit when we have many 1075 # subplots, but it's a tradeoff for a relatively simple implementation 1076 widths = [m.floor(w*width_) for w in grid.xweights] 1077 heights = [m.floor(w*height_) for w in grid.yweights] 1078 1079 # tweak dimensions to allow all plots to have a minimum width, 1080 # this may force the plot to be larger than the requested dimensions, 1081 # but that's the best we can do 1082 for s in grid: 1083 # fit xunits 1084 minwidth = sum(s.xmargin) + max(2, 1085 2*((5 if s.x2 else 4)+len(s.xunits)) 1086 if s.xticklabels is None 1087 else sum(len(t) for t in s.xticklabels)) 1088 # fit yunits 1089 minheight = sum(s.ymargin) + 2 1090 1091 i = 0 1092 while minwidth > sum(widths[s.x:s.x+s.xspan]): 1093 widths[s.x+i] += 1 1094 i = (i + 1) % s.xspan 1095 1096 i = 0 1097 while minheight > sum(heights[s.y:s.y+s.yspan]): 1098 heights[s.y+i] += 1 1099 i = (i + 1) % s.yspan 1100 1101 width_ = sum(widths) 1102 height_ = sum(heights) 1103 1104 # create a plot for each subplot 1105 for s in grid: 1106 # allow subplot params to override global params 1107 define_ = define + s.args.get('define', []) 1108 xlim_ = s.args.get('xlim', xlim) 1109 ylim_ = s.args.get('ylim', ylim) 1110 xlog_ = s.args.get('xlog', False) or xlog 1111 ylog_ = s.args.get('ylog', False) or ylog 1112 1113 # allow shortened ranges 1114 if len(xlim_) == 1: 1115 xlim_ = (0, xlim_[0]) 1116 if len(ylim_) == 1: 1117 ylim_ = (0, ylim_[0]) 1118 1119 # data can be constrained by subplot-specific defines, 1120 # so re-extract for each plot 1121 subdatasets = datasets(results, by, x, y, define_) 1122 1123 # find actual xlim/ylim 1124 xlim_ = ( 1125 xlim_[0] if xlim_[0] is not None 1126 else min(it.chain([0], (k 1127 for r in subdatasets.values() 1128 for k, v in r.items() 1129 if v is not None))), 1130 xlim_[1] if xlim_[1] is not None 1131 else max(it.chain([0], (k 1132 for r in subdatasets.values() 1133 for k, v in r.items() 1134 if v is not None)))) 1135 1136 ylim_ = ( 1137 ylim_[0] if ylim_[0] is not None 1138 else min(it.chain([0], (v 1139 for r in subdatasets.values() 1140 for _, v in r.items() 1141 if v is not None))), 1142 ylim_[1] if ylim_[1] is not None 1143 else max(it.chain([0], (v 1144 for r in subdatasets.values() 1145 for _, v in r.items() 1146 if v is not None)))) 1147 1148 # find actual width/height 1149 subwidth = sum(widths[s.x:s.x+s.xspan]) - sum(s.xmargin) 1150 subheight = sum(heights[s.y:s.y+s.yspan]) - sum(s.ymargin) 1151 1152 # plot! 1153 plot = Plot( 1154 subwidth, 1155 subheight, 1156 xlim=xlim_, 1157 ylim=ylim_, 1158 xlog=xlog_, 1159 ylog=ylog_, 1160 braille=line_chars is None and braille, 1161 dots=line_chars is None and not braille) 1162 1163 for name, dataset in subdatasets.items(): 1164 plot.plot( 1165 sorted((x,y) for x,y in dataset.items()), 1166 color=datacolors_[name], 1167 char=datachars_[name], 1168 line_char=dataline_chars_[name]) 1169 1170 s.plot = plot 1171 s.width = subwidth 1172 s.height = subheight 1173 s.xlim = xlim_ 1174 s.ylim = ylim_ 1175 1176 1177 # now that everything's plotted, let's render things to the terminal 1178 1179 # figure out margin 1180 xmargin = ( 1181 len(ylabel) + (1 if ylabel else 0), 1182 sum(grid[0,0].xmargin[:2]), 1183 ) 1184 ymargin = ( 1185 sum(grid[0,0].ymargin[:2]), 1186 grid[-1,-1].ymargin[-1], 1187 ) 1188 1189 # draw title? 1190 for line in title: 1191 f.writeln('%*s%s' % ( 1192 sum(xmargin[:2]), '', 1193 line.center(width_-xmargin[1]))) 1194 1195 # draw legend_above? 1196 if legend_above and legend_: 1197 for i in range(0, len(legend_), legend_cols): 1198 f.writeln('%*s%s' % ( 1199 max(sum(xmargin[:2]) 1200 + (width_-xmargin[1] 1201 - (sum(legend_widths)+2*(legend_cols-1))) 1202 // 2, 1203 0), '', 1204 ' '.join('%s%s%s' % ( 1205 '\x1b[%sm' % colors_[(i+j) % len(colors_)] 1206 if color else '', 1207 '%-*s' % (legend_widths[j], legend_[i+j]), 1208 '\x1b[m' 1209 if color else '') 1210 for j in range(min(legend_cols, len(legend_)-i))))) 1211 1212 for row in range(height_): 1213 # draw ylabel? 1214 f.write( 1215 '%s ' % ''.join( 1216 ('%*s%s%*s' % ( 1217 ymargin[-1], '', 1218 line.center(height_-sum(ymargin)), 1219 ymargin[0], ''))[row] 1220 for line in ylabel) 1221 if ylabel else '') 1222 1223 for x_ in range(grid.width): 1224 # figure out the grid x/y position 1225 subrow = row 1226 y_ = len(heights)-1 1227 while subrow >= heights[y_]: 1228 subrow -= heights[y_] 1229 y_ -= 1 1230 1231 s = grid[x_, y_] 1232 subrow = row - sum(heights[s.y+s.yspan:]) 1233 1234 # header 1235 if subrow < s.ymargin[-1]: 1236 # draw subtitle? 1237 if subrow < len(s.title): 1238 f.write('%*s%s' % ( 1239 sum(s.xmargin[:2]), '', 1240 s.title[subrow].center(s.width))) 1241 else: 1242 f.write('%*s%*s' % ( 1243 sum(s.xmargin[:2]), '', 1244 s.width, '')) 1245 # draw plot? 1246 elif subrow-s.ymargin[-1] < s.height: 1247 subrow = subrow-s.ymargin[-1] 1248 1249 # draw ysublabel? 1250 f.write('%-*s' % ( 1251 s.xmargin[0], 1252 '%s ' % ''.join( 1253 line.center(s.height)[subrow] 1254 for line in s.ylabel) 1255 if s.ylabel else '')) 1256 1257 # draw yunits? 1258 if subrow == 0 and s.yticklabels != []: 1259 f.write('%*s' % ( 1260 s.xmargin[1], 1261 ((si2 if s.y2 else si)(s.ylim[1]) + s.yunits 1262 if s.yticklabels is None 1263 else s.yticklabels[1]) 1264 + ' ')) 1265 elif subrow == s.height-1 and s.yticklabels != []: 1266 f.write('%*s' % ( 1267 s.xmargin[1], 1268 ((si2 if s.y2 else si)(s.ylim[0]) + s.yunits 1269 if s.yticklabels is None 1270 else s.yticklabels[0]) 1271 + ' ')) 1272 else: 1273 f.write('%*s' % ( 1274 s.xmargin[1], '')) 1275 1276 # draw plot! 1277 f.write(s.plot.draw(subrow, color=color)) 1278 1279 # footer 1280 else: 1281 subrow = subrow-s.ymargin[-1]-s.height 1282 1283 # draw xunits? 1284 if subrow < (1 if s.xticklabels != [] else 0): 1285 f.write('%*s%-*s%*s%*s' % ( 1286 sum(s.xmargin[:2]), '', 1287 (5 if s.x2 else 4) + len(s.xunits) 1288 if s.xticklabels is None 1289 else len(s.xticklabels[0]), 1290 (si2 if s.x2 else si)(s.xlim[0]) + s.xunits 1291 if s.xticklabels is None 1292 else s.xticklabels[0], 1293 s.width - (2*((5 if s.x2 else 4)+len(s.xunits)) 1294 if s.xticklabels is None 1295 else sum(len(t) for t in s.xticklabels)), '', 1296 (5 if s.x2 else 4) + len(s.xunits) 1297 if s.xticklabels is None 1298 else len(s.xticklabels[1]), 1299 (si2 if s.x2 else si)(s.xlim[1]) + s.xunits 1300 if s.xticklabels is None 1301 else s.xticklabels[1])) 1302 # draw xsublabel? 1303 elif (subrow < s.ymargin[1] 1304 or subrow-s.ymargin[1] >= len(s.xlabel)): 1305 f.write('%*s%*s' % ( 1306 sum(s.xmargin[:2]), '', 1307 s.width, '')) 1308 else: 1309 f.write('%*s%s' % ( 1310 sum(s.xmargin[:2]), '', 1311 s.xlabel[subrow-s.ymargin[1]].center(s.width))) 1312 1313 # draw legend_right? 1314 if (legend_right and legend_ 1315 and row >= ymargin[-1] 1316 and row-ymargin[-1] < len(legend_)): 1317 j = row-ymargin[-1] 1318 f.write(' %s%s%s' % ( 1319 '\x1b[%sm' % colors_[j % len(colors_)] if color else '', 1320 legend_[j], 1321 '\x1b[m' if color else '')) 1322 1323 f.writeln() 1324 1325 # draw xlabel? 1326 for line in xlabel: 1327 f.writeln('%*s%s' % ( 1328 sum(xmargin[:2]), '', 1329 line.center(width_-xmargin[1]))) 1330 1331 # draw legend below? 1332 if legend_below and legend_: 1333 for i in range(0, len(legend_), legend_cols): 1334 f.writeln('%*s%s' % ( 1335 max(sum(xmargin[:2]) 1336 + (width_-xmargin[1] 1337 - (sum(legend_widths)+2*(legend_cols-1))) 1338 // 2, 1339 0), '', 1340 ' '.join('%s%s%s' % ( 1341 '\x1b[%sm' % colors_[(i+j) % len(colors_)] 1342 if color else '', 1343 '%-*s' % (legend_widths[j], legend_[i+j]), 1344 '\x1b[m' 1345 if color else '') 1346 for j in range(min(legend_cols, len(legend_)-i))))) 1347 1348 1349 if keep_open: 1350 try: 1351 while True: 1352 if cat: 1353 draw(sys.stdout) 1354 else: 1355 ring = LinesIO() 1356 draw(ring) 1357 ring.draw() 1358 1359 # try to inotifywait 1360 if inotify_simple is not None: 1361 ptime = time.time() 1362 inotifywait(csv_paths) 1363 # sleep for a minimum amount of time, this helps issues 1364 # around rapidly updating files 1365 time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime))) 1366 else: 1367 time.sleep(sleep or 0.1) 1368 except KeyboardInterrupt: 1369 pass 1370 1371 if cat: 1372 draw(sys.stdout) 1373 else: 1374 ring = LinesIO() 1375 draw(ring) 1376 ring.draw() 1377 sys.stdout.write('\n') 1378 else: 1379 draw(sys.stdout) 1380 1381 1382if __name__ == "__main__": 1383 import sys 1384 import argparse 1385 parser = argparse.ArgumentParser( 1386 description="Plot CSV files in terminal.", 1387 allow_abbrev=False) 1388 parser.add_argument( 1389 'csv_paths', 1390 nargs='*', 1391 help="Input *.csv files.") 1392 parser.add_argument( 1393 '-b', '--by', 1394 action='append', 1395 type=lambda x: ( 1396 lambda k,v=None: (k, v.split(',') if v is not None else ()) 1397 )(*x.split('=', 1)), 1398 help="Group by this field. Can rename fields with new_name=old_name.") 1399 parser.add_argument( 1400 '-x', 1401 action='append', 1402 type=lambda x: ( 1403 lambda k,v=None: (k, v.split(',') if v is not None else ()) 1404 )(*x.split('=', 1)), 1405 help="Field to use for the x-axis. Can rename fields with " 1406 "new_name=old_name.") 1407 parser.add_argument( 1408 '-y', 1409 action='append', 1410 type=lambda x: ( 1411 lambda k,v=None: (k, v.split(',') if v is not None else ()) 1412 )(*x.split('=', 1)), 1413 help="Field to use for the y-axis. Can rename fields with " 1414 "new_name=old_name.") 1415 parser.add_argument( 1416 '-D', '--define', 1417 type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)), 1418 action='append', 1419 help="Only include results where this field is this value. May include " 1420 "comma-separated options.") 1421 parser.add_argument( 1422 '--color', 1423 choices=['never', 'always', 'auto'], 1424 default='auto', 1425 help="When to use terminal colors. Defaults to 'auto'.") 1426 parser.add_argument( 1427 '-⣿', '--braille', 1428 action='store_true', 1429 help="Use 2x4 unicode braille characters. Note that braille characters " 1430 "sometimes suffer from inconsistent widths.") 1431 parser.add_argument( 1432 '-.', '--points', 1433 action='store_true', 1434 help="Only draw data points.") 1435 parser.add_argument( 1436 '-!', '--points-and-lines', 1437 action='store_true', 1438 help="Draw data points and lines.") 1439 parser.add_argument( 1440 '--colors', 1441 type=lambda x: [x.strip() for x in x.split(',')], 1442 help="Comma-separated colors to use.") 1443 parser.add_argument( 1444 '--chars', 1445 help="Characters to use for points.") 1446 parser.add_argument( 1447 '--line-chars', 1448 help="Characters to use for lines.") 1449 parser.add_argument( 1450 '-W', '--width', 1451 nargs='?', 1452 type=lambda x: int(x, 0), 1453 const=0, 1454 help="Width in columns. 0 uses the terminal width. Defaults to " 1455 "min(terminal, 80).") 1456 parser.add_argument( 1457 '-H', '--height', 1458 nargs='?', 1459 type=lambda x: int(x, 0), 1460 const=0, 1461 help="Height in rows. 0 uses the terminal height. Defaults to 17.") 1462 parser.add_argument( 1463 '-X', '--xlim', 1464 type=lambda x: tuple( 1465 dat(x) if x.strip() else None 1466 for x in x.split(',')), 1467 help="Range for the x-axis.") 1468 parser.add_argument( 1469 '-Y', '--ylim', 1470 type=lambda x: tuple( 1471 dat(x) if x.strip() else None 1472 for x in x.split(',')), 1473 help="Range for the y-axis.") 1474 parser.add_argument( 1475 '--xlog', 1476 action='store_true', 1477 help="Use a logarithmic x-axis.") 1478 parser.add_argument( 1479 '--ylog', 1480 action='store_true', 1481 help="Use a logarithmic y-axis.") 1482 parser.add_argument( 1483 '--x2', 1484 action='store_true', 1485 help="Use base-2 prefixes for the x-axis.") 1486 parser.add_argument( 1487 '--y2', 1488 action='store_true', 1489 help="Use base-2 prefixes for the y-axis.") 1490 parser.add_argument( 1491 '--xunits', 1492 help="Units for the x-axis.") 1493 parser.add_argument( 1494 '--yunits', 1495 help="Units for the y-axis.") 1496 parser.add_argument( 1497 '--xlabel', 1498 help="Add a label to the x-axis.") 1499 parser.add_argument( 1500 '--ylabel', 1501 help="Add a label to the y-axis.") 1502 parser.add_argument( 1503 '--xticklabels', 1504 type=lambda x: 1505 [x.strip() for x in x.split(',')] 1506 if x.strip() else [], 1507 help="Comma separated xticklabels.") 1508 parser.add_argument( 1509 '--yticklabels', 1510 type=lambda x: 1511 [x.strip() for x in x.split(',')] 1512 if x.strip() else [], 1513 help="Comma separated yticklabels.") 1514 parser.add_argument( 1515 '-t', '--title', 1516 help="Add a title.") 1517 parser.add_argument( 1518 '-l', '--legend-right', 1519 action='store_true', 1520 help="Place a legend to the right.") 1521 parser.add_argument( 1522 '--legend-above', 1523 action='store_true', 1524 help="Place a legend above.") 1525 parser.add_argument( 1526 '--legend-below', 1527 action='store_true', 1528 help="Place a legend below.") 1529 class AppendSubplot(argparse.Action): 1530 @staticmethod 1531 def parse(value): 1532 import copy 1533 subparser = copy.deepcopy(parser) 1534 next(a for a in subparser._actions 1535 if '--width' in a.option_strings).type = float 1536 next(a for a in subparser._actions 1537 if '--height' in a.option_strings).type = float 1538 return subparser.parse_intermixed_args(shlex.split(value or "")) 1539 def __call__(self, parser, namespace, value, option): 1540 if not hasattr(namespace, 'subplots'): 1541 namespace.subplots = [] 1542 namespace.subplots.append(( 1543 option.split('-')[-1], 1544 self.__class__.parse(value))) 1545 parser.add_argument( 1546 '--subplot-above', 1547 action=AppendSubplot, 1548 help="Add subplot above with the same dataset. Takes an arg string to " 1549 "control the subplot which supports most (but not all) of the " 1550 "parameters listed here. The relative dimensions of the subplot " 1551 "can be controlled with -W/-H which now take a percentage.") 1552 parser.add_argument( 1553 '--subplot-below', 1554 action=AppendSubplot, 1555 help="Add subplot below with the same dataset.") 1556 parser.add_argument( 1557 '--subplot-left', 1558 action=AppendSubplot, 1559 help="Add subplot left with the same dataset.") 1560 parser.add_argument( 1561 '--subplot-right', 1562 action=AppendSubplot, 1563 help="Add subplot right with the same dataset.") 1564 parser.add_argument( 1565 '--subplot', 1566 type=AppendSubplot.parse, 1567 help="Add subplot-specific arguments to the main plot.") 1568 parser.add_argument( 1569 '-z', '--cat', 1570 action='store_true', 1571 help="Pipe directly to stdout.") 1572 parser.add_argument( 1573 '-k', '--keep-open', 1574 action='store_true', 1575 help="Continue to open and redraw the CSV files in a loop.") 1576 parser.add_argument( 1577 '-s', '--sleep', 1578 type=float, 1579 help="Time in seconds to sleep between redraws when running with -k. " 1580 "Defaults to 0.01.") 1581 1582 def dictify(ns): 1583 if hasattr(ns, 'subplots'): 1584 ns.subplots = [(dir, dictify(subplot_ns)) 1585 for dir, subplot_ns in ns.subplots] 1586 if ns.subplot is not None: 1587 ns.subplot = dictify(ns.subplot) 1588 return {k: v 1589 for k, v in vars(ns).items() 1590 if v is not None} 1591 1592 sys.exit(main(**dictify(parser.parse_intermixed_args()))) 1593