├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── doc └── readme_screencast.gif ├── LICENSE ├── README.rst ├── setup.py └── pipeplot.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | __pycache__ 4 | .tox 5 | .cache 6 | dist/ 7 | build 8 | .coverage 9 | -------------------------------------------------------------------------------- /doc/readme_screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MyGodIsHe/pipeplot/HEAD/doc/readme_screencast.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Ilya Chistyakov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Pipe Plot 2 | ######### 3 | 4 | .. image:: https://img.shields.io/pypi/v/pipeplot.svg 5 | :target: https://pypi.org/project/pipeplot/ 6 | 7 | .. image:: https://img.shields.io/pypi/pyversions/pipeplot.svg 8 | :target: https://pypi.org/project/pipeplot/ 9 | 10 | .. image:: https://img.shields.io/pypi/dm/pipeplot.svg 11 | :target: https://pypistats.org/packages/pipeplot 12 | 13 | pipeplot draws an interactive graph in a terminal based on data from pipe. 14 | 15 | .. image:: https://raw.githubusercontent.com/MyGodIsHe/pipeplot/master/doc/readme_screencast.gif 16 | 17 | Installation 18 | ************ 19 | 20 | .. code-block:: bash 21 | 22 | pip install pipeplot 23 | 24 | 25 | Examples of using 26 | ***************** 27 | 28 | Graphical ping: 29 | """"""""""""""" 30 | 31 | .. code-block:: bash 32 | 33 | ping ya.ru | grep --line-buffered time | sed -u -e 's#.*time=\([^ ]*\).*#\1#' | pipeplot --min 0 34 | 35 | Chart of deaths per minute from coronavirus: 36 | """""""""""""""""""""""""""""""""""""""""""" 37 | 38 | .. code-block:: bash 39 | 40 | while true; \ 41 | do curl -s https://coronavirus-19-api.herokuapp.com/all \ 42 | | jq '.deaths'; \ 43 | sleep 60; \ 44 | done \ 45 | | pipeplot --color 1 --direction left 46 | 47 | API: https://github.com/javieraviles/covidAPI 48 | 49 | Render graphite to console: 50 | """"""""""""""""""""""""""" 51 | 52 | .. code-block:: bash 53 | 54 | while true; \ 55 | do \ 56 | curl -s 'http://graphite/render?target=my_app_rps_error&format=json&from=-5min&until=now' \ 57 | | jq -c '.[0].datapoints[-1]'; \ 58 | sleep 5; \ 59 | done \ 60 | | sed -u s/null/0/ \ 61 | | stdbuf -oL uniq \ 62 | | stdbuf -oL jq '.[0]' \ 63 | | pipeplot 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import codecs 3 | 4 | 5 | # Copied from (and hacked): 6 | # https://github.com/pypa/virtualenv/blob/develop/setup.py#L42 7 | def get_version(filename): 8 | import os 9 | import re 10 | 11 | here = os.path.dirname(os.path.abspath(__file__)) 12 | f = codecs.open(os.path.join(here, filename), encoding='utf-8') 13 | version_file = f.read() 14 | f.close() 15 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 16 | version_file, re.M) 17 | if version_match: 18 | return version_match.group(1) 19 | raise RuntimeError("Unable to find version string.") 20 | 21 | 22 | with open("README.rst", "r") as fh: 23 | long_description = fh.read() 24 | 25 | 26 | setup( 27 | name='pipeplot', 28 | description='displays an interactive graph based on data from pipe', 29 | long_description=long_description, 30 | version=get_version('pipeplot.py'), 31 | license='MIT', 32 | author='Ilya Chistyakov', 33 | author_email='ilchistyakov@gmail.com', 34 | py_modules=['pipeplot'], 35 | entry_points={ 36 | 'console_scripts': ['pipeplot=pipeplot:run'], 37 | }, 38 | zip_safe=False, 39 | include_package_data=True, 40 | platforms='any', 41 | classifiers=[ 42 | 'Development Status :: 3 - Alpha', 43 | 'Intended Audience :: Developers', 44 | 'License :: OSI Approved :: BSD License', 45 | 'Operating System :: POSIX', 46 | 'Operating System :: MacOS :: MacOS X', 47 | 'Topic :: Software Development :: Testing', 48 | 'Topic :: Software Development :: Libraries', 49 | 'Topic :: Utilities', 50 | 'Programming Language :: Python :: 2', 51 | 'Programming Language :: Python :: 2.7', 52 | 'Programming Language :: Python :: 3', 53 | 'Programming Language :: Python :: 3.7', 54 | ], 55 | url='https://github.com/MyGodIsHe/pipeplot', 56 | project_urls={ 57 | 'Source': 'https://github.com/MyGodIsHe/pipeplot', 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /pipeplot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import collections 5 | import curses 6 | import errno 7 | import fcntl 8 | import itertools 9 | import locale 10 | import os 11 | import sys 12 | import time 13 | 14 | 15 | __version__ = '0.3.3' 16 | 17 | 18 | class CursesContext: 19 | def __init__(self): 20 | self.stdscr = None 21 | 22 | def __enter__(self): 23 | self.stdscr = curses.initscr() 24 | self.stdscr.keypad(True) 25 | self.stdscr.leaveok(False) 26 | curses.noecho() 27 | curses.cbreak() 28 | curses.curs_set(0) 29 | curses.start_color() 30 | curses.use_default_colors() 31 | for i in range(0, curses.COLORS): 32 | curses.init_pair(i, i, -1) 33 | return self 34 | 35 | def __exit__(self, exc_type, exc_val, exc_tb): 36 | if self.stdscr: 37 | self.stdscr.keypad(False) 38 | curses.echo() 39 | curses.nocbreak() 40 | curses.endwin() 41 | self.stdscr = None 42 | 43 | def get_symbol_size(self, symbol): 44 | curses.curs_set(2) 45 | curses.setsyx(0, 0) 46 | self.stdscr.addstr(symbol) 47 | self.stdscr.refresh() 48 | curses.curs_set(0) 49 | return curses.getsyx()[1] 50 | 51 | 52 | DrawSettings = collections.namedtuple( 53 | 'DrawSettings', 54 | ( 55 | 'values', 56 | 'width', 57 | 'height', 58 | 'min_value', 59 | 'current_value', 60 | 'max_value', 61 | 'avg_value', 62 | ), 63 | ) 64 | 65 | 66 | class PlotWidget: 67 | STATS_FLOAT_FORMAT = r'{:6.2f} ' 68 | STATS_INT_FORMAT = r'{:6d} ' 69 | 70 | def __init__(self, stdscr, settings): 71 | self.stdscr = stdscr 72 | self._queue = collections.deque(maxlen=1000) 73 | self.plot_color = curses.color_pair(settings.color) 74 | self.ui_color = curses.color_pair(0) 75 | self.settings = settings 76 | self.min_value = None 77 | self.max_value = None 78 | self.is_natural = True 79 | 80 | def append(self, value): 81 | if self.is_natural: 82 | self.is_natural = value % 1 == 0.0 83 | if self.min_value is None: 84 | self.min_value = self.max_value = value 85 | else: 86 | if self.min_value > value: 87 | self.min_value = value 88 | elif self.max_value < value: 89 | self.max_value = value 90 | self._queue.appendleft(value) 91 | 92 | def draw(self, width, height): 93 | if not width or not height: 94 | return 95 | values = list(itertools.islice(self._queue, 0, width)) 96 | if not values: 97 | return 98 | current_value = values[0] 99 | 100 | if self.settings.scale == 'window': 101 | min_value, max_value = min(values), max(values) 102 | else: 103 | min_value, max_value = self.min_value, self.max_value 104 | settings = DrawSettings( 105 | values=values, 106 | width=width, 107 | height=height, 108 | min_value=min_value, 109 | current_value=current_value, 110 | max_value=max_value, 111 | avg_value=sum(values) / float(len(values)) 112 | ) 113 | 114 | bottom = height - 1 115 | if self.settings.title: 116 | self.add_title(settings) 117 | top = 1 118 | else: 119 | top = 0 120 | self.add_plot(settings, top, bottom) 121 | self.add_stats(settings) 122 | self.stdscr.refresh() 123 | 124 | def add_title(self, settings): 125 | fmt = '{{:^{}}}'.format(settings.width) 126 | title = fmt.format(self.settings.title) 127 | self.add_str(0, 0, title, self.ui_color) 128 | 129 | def add_plot(self, settings, top, bottom): 130 | if self.settings.min is not None: 131 | min_value = self.settings.min 132 | else: 133 | min_value = settings.min_value 134 | if self.settings.max is not None: 135 | max_value = self.settings.max 136 | else: 137 | max_value = settings.max_value 138 | height = bottom - top 139 | 140 | if max_value == min_value: 141 | # draw middle value 142 | height_k = height 143 | values = [0.5] * len(settings.values) 144 | else: 145 | height_k = height / (max_value - min_value) 146 | values = settings.values 147 | 148 | for x, value in enumerate(values): 149 | x *= self.settings.symbol_size 150 | if self.settings.direction == 'left': 151 | x = settings.width - x 152 | value = int((value - min_value) * height_k) 153 | value = height - value 154 | for y in range(0, value): 155 | self.clear_char(x, y + top) 156 | for y in range(value, height + 1): 157 | self.add_str(x, y + top, self.settings.symbol, self.plot_color) 158 | 159 | def add_stats(self, settings): 160 | if self.is_natural: 161 | stats_format = self.STATS_INT_FORMAT 162 | max_value = int(settings.max_value) 163 | current_value = int(settings.current_value) 164 | min_value = int(settings.min_value) 165 | else: 166 | stats_format = self.STATS_FLOAT_FORMAT 167 | max_value = settings.max_value 168 | current_value = settings.current_value 169 | min_value = settings.min_value 170 | stats_box = [ 171 | (' Max: ' + stats_format).format(max_value), 172 | (' Cur: ' + stats_format).format(current_value), 173 | (' Min: ' + stats_format).format(min_value), 174 | (' Avg: ' + self.STATS_FLOAT_FORMAT).format(settings.avg_value), 175 | ] 176 | offset_x = int( 177 | (settings.width - max(len(line) for line in stats_box)) / 2 178 | ) 179 | offset_y = int( 180 | (settings.height - len(stats_box)) / 2 181 | ) 182 | for y, line in enumerate(stats_box): 183 | self.add_str(offset_x, offset_y + y, line, self.ui_color) 184 | 185 | def clear_char(self, x, y): 186 | try: 187 | self.stdscr.addstr(y, x, ' ' * self.settings.symbol_size) 188 | except curses.error: 189 | pass 190 | 191 | def add_str(self, x, y, char, color): 192 | try: 193 | self.stdscr.addstr(y, x, char, color) 194 | except curses.error: 195 | pass 196 | 197 | 198 | def perfect_symbol(char): 199 | if not char: 200 | raise argparse.ArgumentTypeError('not empty') 201 | return char 202 | 203 | 204 | Settings = collections.namedtuple( 205 | 'Settings', 206 | ( 207 | 'title', 208 | 'color', 209 | 'symbol', 210 | 'symbol_size', 211 | 'scale', 212 | 'min', 213 | 'max', 214 | 'direction', 215 | ), 216 | ) 217 | 218 | 219 | def parse_args(): 220 | parser = argparse.ArgumentParser( 221 | description='Displays a graph based on data from the pipe.' 222 | ) 223 | parser.add_argument('--title') 224 | parser.add_argument('--color', default=2, type=int) 225 | parser.add_argument('--symbol', default='█', type=perfect_symbol) 226 | parser.add_argument('--scale', default='all', choices=['all', 'window']) 227 | parser.add_argument( 228 | '--direction', default='right', choices=['left', 'right'], 229 | ) 230 | parser.add_argument('--min', type=float) 231 | parser.add_argument('--max', type=float) 232 | return parser.parse_args() 233 | 234 | 235 | def stdin_iter_py2(): 236 | while True: 237 | try: 238 | line = sys.stdin.readline().strip() 239 | except IOError as exc: 240 | if exc.errno == errno.EAGAIN: 241 | time.sleep(0.1) 242 | continue 243 | raise 244 | if not line: 245 | break 246 | value = float(line) 247 | yield value 248 | time.sleep(0.1) 249 | 250 | 251 | def stdin_iter_py3(): 252 | while True: 253 | line = sys.stdin.readline().strip() 254 | if line: 255 | value = float(line) 256 | yield value 257 | time.sleep(0.1) 258 | 259 | 260 | def main(args): 261 | # init non-blocking stdin 262 | fd = sys.stdin.fileno() 263 | fl = fcntl.fcntl(fd, fcntl.F_GETFL) 264 | fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) 265 | 266 | with CursesContext() as curses_context: 267 | symbol_size = curses_context.get_symbol_size(args.symbol) 268 | settings = Settings( 269 | title=args.title, 270 | color=args.color, 271 | symbol=args.symbol, 272 | symbol_size=symbol_size, 273 | scale=args.scale, 274 | min=args.min, 275 | max=args.max, 276 | direction=args.direction, 277 | ) 278 | plot = PlotWidget(curses_context.stdscr, settings) 279 | 280 | if sys.version_info.major == 2: 281 | std_iter = stdin_iter_py2 282 | else: 283 | std_iter = stdin_iter_py3 284 | 285 | for value in std_iter(): 286 | plot.append(value) 287 | max_y, max_x = curses_context.stdscr.getmaxyx() 288 | plot.draw(max_x, max_y) 289 | 290 | 291 | def run(): 292 | locale.setlocale(locale.LC_ALL, '') 293 | try: 294 | args = parse_args() 295 | main(args) 296 | except KeyboardInterrupt: 297 | pass 298 | except ValueError as exc: 299 | print('Stdin error: {}'.format(exc)) 300 | --------------------------------------------------------------------------------