├── asciietch ├── __init__.py └── graph.py ├── requirements.txt ├── asciietch.gif ├── dev_requirements.txt ├── setup.cfg ├── NOTICE ├── .travis.yml ├── tox.ini ├── AUTHORS.md ├── CONTRIBUTING.md ├── LICENSE ├── .gitignore ├── setup.py ├── README.md └── test └── test_graph.py /asciietch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | parsedatetime==2.3 2 | -------------------------------------------------------------------------------- /asciietch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linkedin/asciietch/HEAD/asciietch.gif -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.5.0 2 | pytest>=3.0.6 3 | setuptools>=30 4 | tox 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E121,E123,E226,W292 3 | max-line-length = 180 4 | 5 | [metadata] 6 | description-file = README.md 7 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2017 LinkedIn Corporation 2 | All Rights Reserved. 3 | 4 | Licensed under the BSD 2-Clause License (the "License"). 5 | See LICENSE in the project root for license information. 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: [3.6, 3.7, 3.8, 3.9] 3 | script: 4 | - tox 5 | - coveralls 6 | install: 7 | - pip install tox 8 | - pip install coveralls 9 | - python setup.py -q install 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | [testenv] 5 | # Need to pass TRAVIS env variables for coveralls support. 6 | passenv = TRAVIS TRAVIS_* 7 | deps = 8 | -rdev_requirements.txt 9 | coveralls 10 | commands= 11 | coverage run --source=asciietch setup.py test 12 | flake8 13 | 14 | [pytest] 15 | timeout = 60 16 | pep8ignore = E501 17 | python_files = test/*.py 18 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Core Contributors 2 | == 3 | - Steven Callister, Original Author, LinkedIn profile: [https://www.linkedin.com/in/stevencallister/](https://www.linkedin.com/in/stevencallister/) 4 | - Emmanuel Shiferaw, LinkedIn profile: [https://www.linkedin.com/in/eshiferaw/](https://www.linkedin.com/in/eshiferaw/) 5 | - Loren Carvalho, LinkedIn profile: [https://www.linkedin.com/in/lorencarvalho/](https://www.linkedin.com/in/lorencarvalho/) 6 | - Matt Knecht, LinkedIn profile: [https://www.linkedin.com/in/matthew-knecht-42411a11/](https://www.linkedin.com/in/matthew-knecht-42411a11/) 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contribution Agreement 2 | ====================== 3 | 4 | As a contributor, you represent that the code you submit is your 5 | original work or that of your employer (in which case you represent 6 | you have the right to bind your employer). By submitting code, you 7 | (and, if applicable, your employer) are licensing the submitted code 8 | to LinkedIn and the open source community subject to the BSD 2-Clause 9 | license. 10 | 11 | Responsible Disclosure of Security Vulnerabilities 12 | ================================================== 13 | 14 | Please do not file reports on Github for security issues. Please 15 | review the guidelines on at (link to more info). Reports should be 16 | encrypted using PGP (link to PGP key) and sent to 17 | security@linkedin.com preferably with the title "Github 18 | linkedin/project - short summary". 19 | 20 | Tips for Getting Your Pull Request Accepted 21 | =========================================== 22 | 23 | 1. Make sure all new features are tested and the tests pass. 24 | 2. Bug fixes must include a test case demonstrating the error that it 25 | fixes. 26 | 3. Open an issue first and seek advice for your change before 27 | submitting a pull request. Large features which have never been 28 | discussed are unlikely to be accepted. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-CLAUSE LICENSE 2 | 3 | Copyright 2017 LinkedIn Corporation. 4 | All Rights Reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the 16 | distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/github/gitignore/blob/master/Python.gitignore 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | activate 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. 2 | # See LICENSE in the project root for license information. 3 | 4 | import io 5 | import venv 6 | import sys 7 | 8 | from pathlib import Path 9 | 10 | from setuptools import setup, find_packages, Command 11 | from setuptools.command.test import test as TestCommand 12 | 13 | if sys.version_info < (3, 6): 14 | print('asciietch requires at least Python 3.6!') 15 | sys.exit(1) 16 | 17 | description = 'A library for graphing charts using ascii characters.' 18 | try: 19 | with io.open('README.md', encoding="utf-8") as fh: 20 | long_description = fh.read() 21 | except IOError: 22 | long_description = description 23 | 24 | 25 | class Tox(TestCommand): 26 | def run_tests(self): 27 | import tox 28 | 29 | errno = -1 30 | try: 31 | tox.session.main() 32 | except SystemExit as e: 33 | errno = e.code 34 | sys.exit(errno) 35 | 36 | 37 | class PyTest(TestCommand): 38 | def run_tests(self): 39 | import pytest 40 | 41 | errno = pytest.main() 42 | sys.exit(errno) 43 | 44 | 45 | class Venv(Command): 46 | user_options = [] 47 | 48 | def initialize_options(self): 49 | """Abstract method that is required to be overwritten""" 50 | 51 | def finalize_options(self): 52 | """Abstract method that is required to be overwritten""" 53 | 54 | def run(self): 55 | venv_path = Path(__file__).absolute().parent / 'venv' / 'asciietch' 56 | print(f'Creating virtual environment in {venv_path}') 57 | venv.main(args=[str(venv_path)]) 58 | print( 59 | 'Linking `activate` to top level of project.\n' 60 | 'To activate, simply run `source activate`.' 61 | ) 62 | activate = Path(venv_path, 'bin', 'activate') 63 | activate_link = Path(__file__).absolute().parent / 'activate' 64 | try: 65 | activate_link.symlink_to(activate) 66 | except FileExistsError: 67 | ... 68 | 69 | 70 | setup( 71 | name='asciietch', 72 | version='1.0.6', 73 | description=description, 74 | long_description=long_description, 75 | long_description_content_type='text/markdown', 76 | url='https://github.com/linkedin/asciietch', 77 | author='Steven R. Callister', 78 | cmdclass={'venv': Venv, 79 | 'test': PyTest, 80 | 'pytest': PyTest, 81 | 'tox': Tox, 82 | }, 83 | license='License :: OSI Approved :: BSD License', 84 | packages=find_packages(), 85 | install_requires=[ 86 | 'parsedatetime==2.4', 87 | 'setuptools>=30', 88 | ], 89 | tests_require=[ 90 | 'flake8>=3.5.0', 91 | 'pytest>=3.0.6', 92 | 'tox', 93 | ], 94 | classifiers=[ 95 | 'Development Status :: 5 - Production/Stable', 96 | 'Environment :: Console', 97 | 'Intended Audience :: Developers', 98 | 'Intended Audience :: Information Technology', 99 | 'Intended Audience :: System Administrators', 100 | 'License :: OSI Approved :: BSD License', 101 | 'Programming Language :: Python :: 3.6', 102 | 'Programming Language :: Python :: 3.7', 103 | 'Programming Language :: Python :: 3.8', 104 | 'Programming Language :: Python :: 3.9', 105 | 'Programming Language :: Python :: 3 :: Only', 106 | 'Operating System :: POSIX :: Linux' 107 | ], 108 | ) 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ascii Etch [![License](https://img.shields.io/badge/License-BSD%202--Clause-orange.svg)](https://opensource.org/licenses/BSD-2-Clause) [![Build Status](https://travis-ci.org/linkedin/asciietch.svg?branch=master)](https://travis-ci.org/linkedin/asciietch) [![Coverage Status](https://coveralls.io/repos/github/linkedin/asciietch/badge.svg)](https://coveralls.io/github/linkedin/asciietch) 2 | A graphing library with the goal of making it simple to graph series of numbers using ascii characters. 3 | 4 | ## Quick Start 5 | To start using Ascii Etch ensure Python 3.6 or higher is installed. Then install asciietch using pip3.6 or higher: 6 | ``` 7 | pip3 install asciietch 8 | ``` 9 | Then import asciietch and begin using it. 10 | ## Examples 11 | ### Graphing 0-4 values as a line graph 12 | ```python 13 | >>> from asciietch.graph import Grapher 14 | >>> g = Grapher() 15 | >>> values = [0, 1, 2, 3, 4] 16 | >>> print(g.asciigraph(values)) 17 | - 18 | / 19 | / 20 | / 21 | / 22 | ``` 23 | ### Graphing 0-4 values as a histogram 24 | ```python 25 | >>> from asciietch.graph import Grapher 26 | >>> g = Grapher() 27 | >>> values = [0, 1, 2, 3, 4] 28 | >>> print(g.asciihist(values)) 29 | ▁▃▅▆█ 30 | ``` 31 | ### Graphing more values 32 | ```python 33 | >>> from asciietch.graph import Grapher 34 | >>> g = Grapher() 35 | >>> values = [0, 1, 2, 3, 4, 4, 3, 2, 1, 2, 2, 2] 36 | >>> print(g.asciigraph(values)) 37 | -- 38 | / \ 39 | / \ --- 40 | / - 41 | / 42 | >>> print(g.asciihist(values)) 43 | ▁▃▅▆██▆▅▃▅▅▅ 44 | ``` 45 | ### Graphing a large set of values and adding labels 46 | ```python 47 | >>> import random 48 | >>> from asciietch.graph import Grapher 49 | >>> g = Grapher() 50 | >>> values = [] 51 | >>> v = 0 52 | >>> for i in range(1000): 53 | ... v = v + random.randint(-1, 1) 54 | ... values.append(v) 55 | >>> print(g.asciigraph(values, max_height=10, max_width=100, label=True)) 56 | ``` 57 | ``` 58 | Upper value: 147.6 ********************************************************************************* 59 | -------- --- 60 | ----/ - \- - 61 | ----/ \----/ \-- 62 | -/ \ 63 | ---- ----/ \------ - ---- 64 | \------/ \----/ \/ \- 65 | \-- 66 | \------- 67 | \------ 68 | \- - 69 | \-/ 70 | Lower value: 85.3 ********************************************* Mean: 122.196 *** Std Dev: 16.20 *** 71 | ``` 72 | 73 | ## Developing 74 | 75 | ```sh 76 | git clone git@github.com:linkedin/asciietch.git 77 | cd asciietch 78 | python3 setup.py venv 79 | source activate 80 | python3 setup.py develop 81 | ``` 82 | 83 | ## Testing 84 | 85 | ```sh 86 | pip3.6 install tox 87 | tox 88 | ``` 89 | 90 | ## Contributing Code 91 | Contributions are welcome, see [Contribution guidelines for this project](CONTRIBUTING.md) 92 | -------------------------------------------------------------------------------- /test/test_graph.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. 2 | # See LICENSE in the project root for license information. 3 | import sys 4 | import logging 5 | from asciietch.graph import Grapher 6 | 7 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | def test_ascii_scale_values_down(): 12 | 13 | g = Grapher() 14 | 15 | # Transpose 20-40 to 0-20 16 | values = range(20, 41) 17 | scaled_values = g._scale_y_values(values=values, new_min=0, new_max=20, scale_old_from_zero=False) 18 | assert scaled_values[0] == 0.0 19 | assert scaled_values[9] == 9.00 20 | assert scaled_values[20] == 20.0 21 | 22 | # Transpose 20-40 to 0-20, use 0 as the minimum range for 20-40 23 | values = range(20, 41) 24 | scaled_values = g._scale_y_values(values=values, new_min=0, new_max=20, scale_old_from_zero=True) 25 | assert scaled_values[0] == 10.0 26 | assert scaled_values[9] == 14.5 27 | assert scaled_values[20] == 20.0 28 | 29 | 30 | def test_ascii_scale_values_equal(): 31 | g = Grapher() 32 | 33 | # Transpose 20-40 to 0-20 34 | values = range(0, 21) 35 | scaled_values = g._scale_y_values(values=values, new_min=0, new_max=20) 36 | assert scaled_values[0] == 0.0 37 | assert scaled_values[9] == 9.00 38 | assert scaled_values[20] == 20.0 39 | 40 | 41 | def test_ascii_scale_values_up(): 42 | g = Grapher() 43 | 44 | # Transpose 20-40 to 0-20 45 | values = range(0, 11) 46 | scaled_values = g._scale_y_values(values=values, new_min=0, new_max=20) 47 | assert scaled_values[0] == 0.0 48 | assert scaled_values[4] == 8.00 49 | assert scaled_values[10] == 20.0 50 | 51 | 52 | def test_ascii_compress_values(): 53 | g = Grapher() 54 | 55 | # Test that normal compression works 56 | values = list(range(0, 10)) 57 | assert g._scale_x_values(values=values, max_width=5) == [0.5, 2.5, 4.5, 6.5, 8.5] 58 | 59 | # Test that we can handle remainders 60 | values = list(range(0, 11)) 61 | assert g._scale_x_values(values=values, max_width=5) == [0.5, 2.5, 4.5, 6.5, 9] 62 | 63 | # Test that we can fit the max_width exactly 64 | values = list(range(1, 12)) 65 | assert g._scale_x_values(values=values, max_width=3) == [2, 5.5, 9.5] 66 | 67 | 68 | def test_sort_timeseries_values(): 69 | g = Grapher() 70 | time_data = { 71 | '1578162600': 5, 72 | '1577903400': 2, 73 | '1577817000': 1, 74 | } 75 | sorted_values = g._sort_timeseries_values(time_data) 76 | expected_values = [ 77 | ('1577817000', 1), 78 | ('1577903400', 2), 79 | ('1578162600', 5), 80 | ] 81 | assert expected_values == sorted_values 82 | 83 | 84 | def test_ascii_round_floats_to_ints(): 85 | g = Grapher() 86 | values = [0.49, 0.51, 2.49, 2.51] 87 | assert g._round_floats_to_ints(values=values) == [0, 1, 2, 3] 88 | 89 | 90 | def test_get_ascii_field(): 91 | g = Grapher() 92 | values = [0, 1, 2] 93 | field = g._get_ascii_field(values=values) 94 | assert field[0][0] == '/' 95 | assert field[1][1] == '/' 96 | assert field[2][2] == '-' 97 | assert field[2][0] == ' ' 98 | 99 | 100 | def test_assign_ascii_character(): 101 | g = Grapher() 102 | values = [0, 1, 2] 103 | # Confirm every combo has a character 104 | for a in values: 105 | for b in values: 106 | for c in values: 107 | assert g._assign_ascii_character(a, b, c) != '?' 108 | 109 | # Spot check character assignment 110 | assert g._assign_ascii_character(0, 0, 0) == '-' 111 | assert g._assign_ascii_character(1, 0, 0) == '\\' 112 | assert g._assign_ascii_character(0, 0, 1) == '/' 113 | 114 | 115 | def test_draw_ascii_graph(): 116 | g = Grapher() 117 | values = [0, 1, 2, 2, 1, 0, 3, 0] 118 | field = g._get_ascii_field(values=values) 119 | graph_string = g._draw_ascii_graph(field) 120 | 121 | assert '\\' in graph_string 122 | assert '-' in graph_string 123 | assert '/' in graph_string 124 | assert '|' in graph_string 125 | assert graph_string.count('\n') == 3 126 | for line in graph_string.splitlines(): 127 | assert len(line) == 8 128 | 129 | 130 | def test_draw_graph_with_labels(): 131 | g = Grapher() 132 | values = [x % 3 for x in range(100)] 133 | non_graph_lines = 3 # 1 extra line above the graph, 2 extra lines below the graph 134 | for height in range(20, 30): 135 | for width in range(70, 100): 136 | graph = g.asciigraph(values=values, max_height=height, max_width=width, label=True) 137 | graph_split = graph.splitlines() 138 | assert len(graph_split) - non_graph_lines == height 139 | assert 'Upper value' in graph 140 | assert '***' in graph 141 | assert 'Mean' in graph 142 | assert 'Std Dev' in graph 143 | for line in graph.splitlines(): 144 | assert len(line) == width 145 | 146 | 147 | def test_timestamp_scaling(): 148 | g = Grapher() 149 | ts = 1512431401.0 150 | values = [(ts + v, v % 10) for v in range(100)] 151 | log.debug(f"values: {values}") 152 | result = g._scale_x_values_timestamps(values=values, max_width=1) 153 | log.debug(f"result: {result}") 154 | assert len(result) == 1 155 | assert 4.5 in result 156 | 157 | 158 | def test_timestamp_as_string(): 159 | g = Grapher() 160 | ts = 1512431401.0 161 | values = [(str(ts + v), v % 10) for v in range(100)] 162 | log.debug(f"values: {values}") 163 | result = g._scale_x_values_timestamps(values=values, max_width=1) 164 | log.debug(f"result: {result}") 165 | assert len(result) == 1 166 | assert 4.5 in result 167 | 168 | 169 | def test_timestamp_as_string_with_none_values(): 170 | g = Grapher() 171 | ts = 1512431401.0 172 | values = [(str(ts + v), None if v == 0 else v % 10) for v in range(100)] 173 | log.debug(f"values: {values}") 174 | result = g._scale_x_values_timestamps(values=values, max_width=1) 175 | log.debug(f"result: {result}") 176 | assert len(result) == 1 177 | assert result[0] > 4.5 and result[0] < 5 178 | 179 | 180 | class TestAsciiHist: 181 | g = Grapher() 182 | values = [1, 2, 3, 4] 183 | 184 | def test_hist_with_no_axis_scaling_has_same_length_as_input_values(self): 185 | ascii_histogram = self.g.asciihist(self.values) 186 | assert len(ascii_histogram) == len(self.values) 187 | 188 | def test_hist_prints_one_line(self): 189 | ascii_histogram = self.g.asciihist(self.values) 190 | assert len(ascii_histogram.splitlines()) == 1 191 | 192 | def test_hist_with_label_prints_three_lines(self): 193 | ascii_histogram = self.g.asciihist(self.values, label=True) 194 | assert len(ascii_histogram.splitlines()) == 3 195 | 196 | def test_hist_with_label_prints_max_and_min_values(self): 197 | ascii_histogram = self.g.asciihist(self.values, label=True) 198 | top_line, graph_line, bottom_line = ascii_histogram.splitlines() 199 | max_val = max(self.values) 200 | min_val = min(self.values) 201 | 202 | assert f'Upper value: {max_val}' in top_line 203 | assert f'Lower value: {min_val}' in bottom_line 204 | 205 | def test_hist_with_label_prints_data_statistics(self): 206 | ascii_histogram = self.g.asciihist(self.values, label=True) 207 | top_line, graph_line, bottom_line = ascii_histogram.splitlines() 208 | 209 | assert 'Mean:' in bottom_line 210 | assert 'Std Dev:' in bottom_line 211 | 212 | def test_hist_with_max_width_less_than_number_of_values(self): 213 | """Graph should have a max width of max_width.""" 214 | ascii_histogram = self.g.asciihist(self.values, max_width=3) 215 | assert len(ascii_histogram) == 3 216 | 217 | 218 | class TestSurroundWithLabel: 219 | graph_string = \ 220 | ''' 221 | / 222 | / 223 | / 224 | ''' 225 | g = Grapher() 226 | 227 | def test_surround_adds_two_extra_lines(self): 228 | """Surround with label adds a line above and below the graph string.""" 229 | label_surrounded_string = self.g._surround_with_label( 230 | self.graph_string, 231 | 100, 232 | 4, 233 | 0, 234 | 1.29, 235 | 2.5 236 | ) 237 | assert len(label_surrounded_string.splitlines()) == len(self.graph_string.splitlines()) + 2 238 | 239 | def test_surround_adds_three_lines_with_timestamps(self): 240 | """Surround with label will add another line for timestamps at the end.""" 241 | label_surrounded_string = self.g._surround_with_label( 242 | self.graph_string, 243 | 100, 244 | 4, 245 | 0, 246 | 1.29, 247 | 2.5, 248 | start_ctime='Wed Jan 1 00:00:00 2020', 249 | end_ctime='Sun Jan 5 00:00:00 2020', 250 | ) 251 | assert len(label_surrounded_string.splitlines()) == len(self.graph_string.splitlines()) + 3 252 | -------------------------------------------------------------------------------- /asciietch/graph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2017 LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. 3 | # See LICENSE in the project root for license information. 4 | import random 5 | import statistics 6 | from datetime import datetime 7 | 8 | BORDER_FILL_CHARACTER = '*' 9 | DEFAULT_MAX_WIDTH = 180 10 | 11 | 12 | class Grapher(object): 13 | 14 | def _scale_x_values(self, values, max_width): 15 | '''Scale X values to new width''' 16 | adjusted_values = list(values) 17 | if len(adjusted_values) > max_width: 18 | 19 | def get_position(current_pos): 20 | return len(adjusted_values) * current_pos // max_width 21 | 22 | adjusted_values = [statistics.mean(adjusted_values[get_position(i):get_position(i + 1)]) for i in range(max_width)] 23 | 24 | return adjusted_values 25 | 26 | def _scale_x_values_timestamps(self, values, max_width): 27 | '''Scale X values to new width based on timestamps''' 28 | first_timestamp = float(values[0][0]) 29 | last_timestamp = float(values[-1][0]) 30 | step_size = (last_timestamp - first_timestamp) / max_width 31 | 32 | values_by_column = [[] for _ in range(max_width)] 33 | for timestamp, value in values: 34 | if value is None: 35 | continue 36 | timestamp = float(timestamp) 37 | column = (timestamp - first_timestamp) // step_size 38 | column = int(min(column, max_width - 1)) # Don't go beyond the last column 39 | values_by_column[column].append(value) 40 | 41 | adjusted_values = [statistics.mean(values) if values else 0 for values in values_by_column] # Average each column, 0 if no values 42 | 43 | return adjusted_values 44 | 45 | def _scale_y_values(self, values, new_max, new_min=0, scale_old_from_zero=True): 46 | ''' 47 | Take values and transmute them into a new range 48 | ''' 49 | # Scale Y values - Create a scaled list of values to use for the visual graph 50 | scaled_values = [] 51 | y_min_value = min(values) 52 | if scale_old_from_zero: 53 | y_min_value = 0 54 | y_max_value = max(values) 55 | # Prevents division by zero if all values are the same 56 | old_range = (y_max_value - y_min_value) or 1 57 | new_range = (new_max - new_min) # max_height is new_max 58 | for old_value in values: 59 | new_value = (((old_value - y_min_value) * new_range) / old_range) + new_min 60 | scaled_values.append(new_value) 61 | return scaled_values 62 | 63 | def _round_floats_to_ints(self, values): 64 | adjusted_values = [int(round(x)) for x in values] 65 | return adjusted_values 66 | 67 | def _get_ascii_field(self, values): 68 | '''Create a representation of an ascii graph using two lists in this format: field[x][y] = "char"''' 69 | 70 | empty_space = ' ' 71 | 72 | # This formats as field[x][y] 73 | field = [[empty_space for y in range(max(values) + 1)] for x in range(len(values))] 74 | 75 | # Draw graph into field 76 | for x in range(len(values)): 77 | y = values[x] 78 | y_prev = values[x - 1] if x - 1 in range(len(values)) else y 79 | y_next = values[x + 1] if x + 1 in range(len(values)) else y 80 | # Fill empty space 81 | if abs(y_prev - y) > 1: 82 | # Fill space between y and y_prev 83 | step = 1 if y_prev - y > 0 else -1 84 | 85 | # We don't want the first item to be inclusive, so we use step instead of y+1 since step can be negative 86 | for h in range(y + step, y_prev, step): 87 | if field[x][h] is empty_space: 88 | field[x][h] = '|' 89 | 90 | # Assign the character to be placed into the graph 91 | char = self._assign_ascii_character(y_prev, y, y_next) 92 | 93 | field[x][y] = char 94 | return field 95 | 96 | def _assign_ascii_character(self, y_prev, y, y_next): # noqa for complexity 97 | '''Assign the character to be placed into the graph''' 98 | char = '?' 99 | if y_next > y and y_prev > y: 100 | char = '-' 101 | elif y_next < y and y_prev < y: 102 | char = '-' 103 | elif y_prev < y and y == y_next: 104 | char = '-' 105 | elif y_prev == y and y_next < y: 106 | char = '-' 107 | elif y_next > y: 108 | char = '/' 109 | elif y_next < y: 110 | char = '\\' 111 | elif y_prev > y: 112 | char = '\\' 113 | elif y_next == y: 114 | char = '-' 115 | return char 116 | 117 | def _draw_ascii_graph(self, field): 118 | '''Draw graph from field double nested list, format field[x][y] = char''' 119 | row_strings = [] 120 | for y in range(len(field[0])): 121 | row = '' 122 | for x in range(len(field)): 123 | row += field[x][y] 124 | row_strings.insert(0, row) 125 | graph_string = '\n'.join(row_strings) 126 | return graph_string 127 | 128 | def asciigraph(self, values, max_height=None, max_width=None, label=False): 129 | ''' 130 | Accepts a list of y values and returns an ascii graph 131 | Optionally values can also be a dictionary with a key of timestamp, and a value of value. InGraphs returns data in this format for example. 132 | ''' 133 | start_ctime = None 134 | end_ctime = None 135 | 136 | max_width = max_width or DEFAULT_MAX_WIDTH 137 | 138 | # If this is a dict of timestamp -> value, sort the data, store the start/end time, and convert values to a list of values 139 | if isinstance(values, dict): 140 | time_series_sorted = self._sort_timeseries_values(values) 141 | start_ctime, end_ctime = self._get_start_and_end_ctimes(time_series_sorted) 142 | values = self._scale_x_values_timestamps(values=time_series_sorted, max_width=max_width) 143 | values = [value for value in values if value is not None] 144 | 145 | if not max_height: 146 | max_height = min(20, max(values)) 147 | 148 | # Do value adjustments 149 | adjusted_values = self._scale_x_values(values=values, max_width=max_width) 150 | upper_value = max(adjusted_values) # Getting upper/lower after scaling x values so we don't label a spike we can't see 151 | lower_value = min(adjusted_values) 152 | adjusted_values = self._scale_y_values(values=adjusted_values, new_min=0, new_max=max_height, scale_old_from_zero=False) 153 | adjusted_values = self._round_floats_to_ints(values=adjusted_values) 154 | 155 | # Obtain Ascii Graph String 156 | field = self._get_ascii_field(adjusted_values) 157 | graph_string = self._draw_ascii_graph(field=field) 158 | 159 | # Label the graph 160 | 161 | if label: 162 | stdev = statistics.stdev(values) 163 | mean = statistics.mean(values) 164 | 165 | result = self._surround_with_label(graph_string, 166 | max_width, 167 | upper_value, 168 | lower_value, 169 | stdev, 170 | mean, 171 | start_ctime, 172 | end_ctime) 173 | else: 174 | result = graph_string 175 | return result 176 | 177 | def _get_start_and_end_ctimes(self, time_series_sorted): 178 | """Get the start and end times of a sorted time series data as ctime. """ 179 | start_timestamp = time_series_sorted[0][0] 180 | end_timestamp = time_series_sorted[-1][0] 181 | 182 | start_ctime = datetime.fromtimestamp(float(start_timestamp)).ctime() 183 | end_ctime = datetime.fromtimestamp(float(end_timestamp)).ctime() 184 | 185 | return start_ctime, end_ctime 186 | 187 | def _sort_timeseries_values(self, values_dict): 188 | """Sort the data by time if data is given as a time->value dictionary. 189 | 190 | Sort the timeseries data and return as list of tuples. 191 | """ 192 | return sorted(values_dict.items(), key=lambda x: x[0]) 193 | 194 | def _surround_with_label(self, 195 | graph_string, 196 | max_width, 197 | max_val, 198 | min_val, 199 | stdev, 200 | mean, 201 | start_ctime=None, 202 | end_ctime=None): 203 | """Surround the graph string with labels. 204 | 205 | It adds a top label with the max value of the data. 206 | And a bottom label with min value and data statistics. 207 | """ 208 | top_label = f'Upper value: {max_val:.2f} '.ljust(max_width, BORDER_FILL_CHARACTER) 209 | lower = f'Lower value: {min_val:.2f} ' 210 | stats = f' Mean: {mean:.2f} *** Std Dev: {stdev:.2f} ******' 211 | fill_length = max_width - len(lower) - len(stats) 212 | stat_label = f'{lower}{"*" * fill_length}{stats}' 213 | 214 | result = top_label + '\n' + graph_string + '\n' + stat_label 215 | 216 | if start_ctime and end_ctime: 217 | fill_length = max_width - len(start_ctime) - len(end_ctime) 218 | time_label = f'{start_ctime} {" " * fill_length}{end_ctime}\n' 219 | result += '\n' + time_label 220 | 221 | return result 222 | 223 | def asciihist(self, values, max_width=None, label=False): 224 | """Draw an ascii histogram of the given values. 225 | 226 | Values can also be a dictionary of timestamp and data. 227 | """ 228 | allowed_bars_in_order = ('▁', '▂', '▃', '▄', '▅', '▆', '▇', '█') 229 | 230 | start_ctime = None 231 | end_ctime = None 232 | 233 | max_width = max_width or DEFAULT_MAX_WIDTH 234 | 235 | max_height = len(allowed_bars_in_order) - 1 236 | 237 | # If this is a dict of timestamp -> value, sort the data, store the start/end time, and convert values to a list of values 238 | if isinstance(values, dict): 239 | time_series_sorted = self._sort_timeseries_values(values) 240 | start_ctime, end_ctime = self._get_start_and_end_ctimes(time_series_sorted) 241 | values = self._scale_x_values_timestamps(values=time_series_sorted, max_width=max_width) 242 | 243 | values = [value for value in values if value is not None] 244 | 245 | # Do value adjustments 246 | adjusted_values = self._scale_x_values(values=values, max_width=max_width) 247 | 248 | # Getting upper/lower after scaling x values so we don't label a spike we can't see 249 | upper_value = max(adjusted_values) 250 | lower_value = min(adjusted_values) 251 | 252 | adjusted_values = self._scale_y_values(values=adjusted_values, new_min=0, 253 | new_max=max_height, scale_old_from_zero=False) 254 | adjusted_values = self._round_floats_to_ints(values=adjusted_values) 255 | 256 | # Obtain Ascii Histogram String 257 | field = [allowed_bars_in_order[val] for val in adjusted_values] 258 | 259 | graph_string = self._draw_ascii_graph(field=field) 260 | 261 | # Label the graph 262 | if label: 263 | stdev = statistics.stdev(values) 264 | mean = statistics.mean(values) 265 | result = self._surround_with_label(graph_string, 266 | max_width, 267 | upper_value, 268 | lower_value, 269 | stdev, 270 | mean, 271 | start_ctime, 272 | end_ctime) 273 | else: 274 | result = graph_string 275 | 276 | return result 277 | 278 | 279 | if __name__ == "__main__": 280 | g = Grapher() 281 | values = [] 282 | v = 0 283 | for i in range(1000): 284 | v = v + random.randint(-1, 1) 285 | values.append(v) 286 | print(g.asciigraph(values=values, max_height=20, max_width=100)) 287 | print(g.asciihist(values=values, max_width=100)) 288 | --------------------------------------------------------------------------------