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