diff --git a/.gitignore b/.gitignore index 4342e49..1a163cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc venv/ +.vim/ .vscode/ .coverage htmlcov/ diff --git a/data/.vim/undodir/D%%Github Contribs%termgraph%data%ex1.dat b/data/.vim/undodir/D%%Github Contribs%termgraph%data%ex1.dat new file mode 100644 index 0000000..8ebc51e Binary files /dev/null and b/data/.vim/undodir/D%%Github Contribs%termgraph%data%ex1.dat differ diff --git a/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%oop.py b/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%oop.py new file mode 100644 index 0000000..dc8769e Binary files /dev/null and b/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%oop.py differ diff --git a/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%termgraph.py b/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%termgraph.py new file mode 100644 index 0000000..6e8e920 Binary files /dev/null and b/data/.vim/undodir/D%%Github Contribs%termgraph%termgraph%termgraph.py differ diff --git a/data/ex4.dat b/data/ex4.dat index dd03b42..7ef6e26 100644 --- a/data/ex4.dat +++ b/data/ex4.dat @@ -6,4 +6,4 @@ 2010,50.21,7 2011,508.97,10.45 2012,212.05,20.2 -2014,30.0,20.0 +2014,30.0,20.0 \ No newline at end of file diff --git a/data/ex5.dat b/data/ex5.dat index 99eae6a..87fd105 100644 --- a/data/ex5.dat +++ b/data/ex5.dat @@ -4,6 +4,6 @@ 2008,231.23,50.0,80.6 2009,16.43,53.1,76.54 2010,50.21,7,0.0 -2011,508.97,10.45,7.0 +2011,508.97,10.45,-27.0 2012,212.05,20.2,-4.4 2014,30.0,9,9.8 diff --git a/termgraph/example.py b/termgraph/example.py new file mode 100644 index 0000000..32f448d --- /dev/null +++ b/termgraph/example.py @@ -0,0 +1,13 @@ +from module import Data, BarChart, Args, Colors + +data = Data([[765, 787], [781, 769]], ["6th G", "7th G"], ["Boys", "Girls"]) +chart = BarChart( + data, + Args( + title="Total Marks Per Class", + colors=[Colors.Red, Colors.Magenta], + space_between=True, + ), +) + +chart.draw() diff --git a/termgraph/module.py b/termgraph/module.py new file mode 100644 index 0000000..1fd0cba --- /dev/null +++ b/termgraph/module.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +# coding=utf-8 +"""This module allows drawing basic graphs in the terminal.""" + +# termgraph.py - draw basic graphs on terminal +# https://github.com/mkaz/termgraph + +from __future__ import print_function +import sys, math, os +import colorama +from typing import Dict, List, Tuple, Union +from utils import cvt_to_readable + +DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +DELIM = "," +TICK = "▇" +SM_TICK = "▏" + +# Commented it out cause I don't know what its purpose is. +# And the code was running just fine without it. +# I am sorry if I am being stupid here. +# try: +# range = xrange +# except NameError: +# pass + +colorama.init() + + +class Colors(object): + """Class representing available color values for graphs.""" + + Black = 90 + Red = 91 + Green = 92 + Yellow = 93 + Blue = 94 + Magenta = 95 + Cyan = 96 + + +class Data(object): + """Class representing the data for the chart.""" + + def __init__( + self, + data: List, + labels: List[str], + categories: List[str] = [], + ): + """Initialize data + + :labels: The labels of the data + :data: The data to graph on the chart + :categories: The categories of the data + """ + + if len(data) != len(labels): + raise Exception("The dimensions of the data and labels must be the same") + + self.labels = labels + self.data = data + self.categories = categories + self.dims = self._find_dims(data, labels) + + def _find_dims(self, data, labels, dims=[]) -> Tuple[int]: + if all([isinstance(data[i], list) for i in range(len(data))]): + last = None + + for i in range(len(data)): + curr = self._find_dims(data[i], labels[i], dims + [len(data)]) + + if i != 0 and last != curr: + raise Exception( + f"The inner dimensions of the data are different\nThe dimensions of {data[i - 1]} is different than the dimensions of {data[i]}" + ) + + last = curr + + return last + + else: + dims.append(len(data)) + + return tuple(dims) + + def find_min(self) -> Union[int, float]: + """Return the minimum value in sublist of list.""" + + return min([min(sublist) for sublist in self.data]) + + def find_max(self) -> Union[int, float]: + """Return the maximum value in sublist of list.""" + + return max([max(sublist) for sublist in self.data]) + + def find_min_label_length(self) -> int: + """Return the minimum length for the labels.""" + + return min([len(label) for label in self.labels]) + + def find_max_label_length(self) -> int: + """Return the maximum length for the labels.""" + + return max([len(label) for label in self.labels]) + + def __str__(self): + """Returns the string representation of the data. + :returns: The data in a tabular format + """ + + maxlen_labels = max([len(label) for label in self.labels] + [len("Labels")]) + 1 + + if len(self.categories) == 0: + maxlen_data = max([len(str(data)) for data in self.data]) + 1 + + else: + maxlen_categories = max([len(category) for category in self.categories]) + maxlen_data = ( + max( + [ + len(str(self.data[i][j])) + for i in range(len(self.data)) + for j in range(len(self.categories)) + ] + ) + + maxlen_categories + + 4 + ) + + output = [ + f"{ ' ' * (maxlen_labels - len('Labels')) }Labels | Data", + f"{ '-' * (maxlen_labels + 1) }|{ '-' * (maxlen_data + 1) }", + ] + + for i in range(len(self.data)): + line = f"{ ' ' * (maxlen_labels - len(self.labels[i])) + self.labels[i] } |" + + if len(self.categories) == 0: + line += f" {self.data[i]}" + + else: + for j in range(len(self.categories)): + if j == 0: + line += f" ({self.categories[j]}) {self.data[i][0]}\n" + + else: + line += f"{ ' ' * maxlen_labels } | ({self.categories[j]}) {self.data[i][j]}" + line += ( + "\n" + if j < len(self.categories) - 1 + else f"\n{ ' ' * maxlen_labels } |" + ) + + output.append(line) + + return "\n".join(output) + + def __repr__(self): + return f"Data(data={ self.data if len(str(self.data)) < 25 else str(self.data)[:25] + '...' }, labels={self.labels}, categories={self.categories})" + + +class Args(object): + """Class representing the arguments to modify the graph.""" + + default = { + "filename": "-", + "title": None, + "width": 50, + "format": "{:<5.2f}", + "suffix": "", + "no_labels": False, + "no_values": False, + "space_between": False, + "colors": None, + "vertical": False, + "stacked": False, + "histogram": False, + "bins": 5, + "different_scale": False, + "calendar": False, + "start_dt": None, + "custom_tick": "", + "delim": "", + "verbose": False, + "label_before": False, + } + + def __init__(self, **kwargs: Dict): + """Initialize the Args object.""" + + self.args = dict(self.default) + + for arg, value in list(kwargs.items()): + if arg in self.args: + self.args[arg] = value + else: + raise Exception(f"Invalid Argument: {arg}") + + def get_arg(self, arg: str) -> Union[int, str, bool, None]: + """Returns the value for the argument given. + + :arg: The name of the argument. + :returns: The value of the argument. + + """ + + if arg in self.args: + return self.args[arg] + else: + raise Exception(f"Invalid Argument: {arg}") + + def update_args(self, **kwargs) -> None: + """Updates the arguments""" + + for arg, value in list(kwargs.items()): + if arg in self.args: + self.args[arg] = value + else: + raise Exception(f"Invalid Argument: {arg}") + + +class Chart(object): + """Class representing a chart""" + + def __init__(self, data: Data, args: Args()): + """Initialize the chart + + :data: The data to be displayed on the chart + :args: The arguments for the chart + + """ + + self.data = data + self.args = args + self.normal_data = self._normalize() + + def draw(self) -> None: + """Draw the chart with the given data""" + + raise NotImplementedError() + + def _print_header(self) -> None: + title = self.args.get_arg("title") + + if title is not None: + print(f"# {title}\n") + + if len(self.data.categories) > 0: + colors = self.args.get_arg("colors") + + for i in range(len(self.data.categories)): + if colors is not None: + sys.stdout.write( + "\033[{color_i}m".format(color_i=colors[i]) + ) # Start to write colorized. + sys.stdout.write(f"\033[{colors[i]}m") # Start to write colorized. + + sys.stdout.write(TICK + " " + self.data.categories[i] + " ") + if colors: + sys.stdout.write("\033[0m") # Back to original. + + print("\n\n") + + def _normalize(self) -> List[float]: + """Normalize the data and return it.""" + + # We offset by the minimum if there's a negative. + data_offset = [] + min_datum = self.data.find_min() + + if min_datum < 0: + min_datum = abs(min_datum) + + data_offset = [[d + min_datum for d in datum] for datum in self.data.data] + + else: + data_offset = self.data.data + + max_datum = max([max(sublist) for sublist in data_offset]) + + # max_dat / width is the value for a single tick. norm_factor is the + # inverse of this value + # If you divide a number to the value of single tick, you will find how + # many ticks it does contain basically. + norm_factor = self.args.get_arg("width") / float(max_datum) + normal_data = [[v * norm_factor for v in datum] for datum in data_offset] + + return normal_data + + +class HorizontalChart(Chart): + """Class representing a horizontal chart""" + + def __init__(self, data: Data, args: Args = Args()): + """Initialize the chart + + :data: The data to be displayed on the chart + :args: The arguments for the chart + + """ + + super().__init__(data, args) + + def print_row( + self, + value: Union[int, float], + num_blocks: Union[int, float], + val_min: Union[int, float], + color: int, + label: bool = False, + tail: bool = False, + ) -> None: + """A method to print a row for a horizontal graphs. + i.e: + 1: ▇▇ 2 + 2: ▇▇▇ 3 + 3: ▇▇▇▇ 4 + """ + doprint = self.args.get_arg("label_before") and not self.args.get_arg( + "vertical" + ) + + sys.stdout.write("\033[0m") # no color + + if value == 0.0: + sys.stdout.write(f"\033[{Colors.black}m") # dark gray + + if doprint: + print(label, tail, " ", end="") + + if (num_blocks < 1 and (value > val_min or value > 0)) or ( + self.args.get_arg("label_before") and value == 0.0 + ): + # Print something if it's not the smallest + # and the normal value is less than one. + sys.stdout.write(SM_TICK) + + else: + if color: + sys.stdout.write(f"\033[{color}m") # Start to write colorized. + + for _ in range(num_blocks): + sys.stdout.write(TICK) + + if color: + sys.stdout.write("\033[0m") # Back to original. + + if doprint: + print() + + +class BarChart(HorizontalChart): + """Class representing a bar chart""" + + def __init__(self, data: Data, args: Args = Args()): + """Initialize the bar chart + + :data: The data to be displayed on the chart + :args: The arguments for the chart + + """ + + super().__init__(data, args) + + def draw(self) -> None: + """Draws the chart""" + self._print_header() + + colors = ( + self.args.get_arg("colors") + if self.args.get_arg("colors") != None + else [None] * self.data.dims[1] + ) + + val_min = self.data.find_min() + + for i in range(len(self.data.labels)): + if self.args.get_arg("no_labels"): + # Hide the labels. + label = "" + else: + if self.args.get_arg("label_before"): + fmt = "{:<{x}}" + else: + fmt = "{:<{x}}: " + + label = fmt.format( + self.data.labels[i], x=self.data.find_max_label_length() + ) + + values = self.data.data[i] + num_blocks = self.normal_data[i] + + if self.args.get_arg("space_between") and i != 0: + print() + + for j in range(len(values)): + # In Multiple series graph 1st category has label at the beginning, + # whereas the rest categories have only spaces. + if j > 0: + len_label = len(label) + label = " " * len_label + + if self.args.get_arg("label_before"): + fmt = "{}{}{}" + + else: + fmt = " {}{}{}" + + if self.args.get_arg("no_values"): + tail = self.args.get_arg("suffix") + + else: + val, deg = cvt_to_readable(values[j]) + tail = fmt.format( + self.args.get_arg("format").format(val), + deg, + self.args.get_arg("suffix"), + ) + + if colors: + color = colors[j] + + else: + color = None + + if not self.args.get_arg("label_before") and not self.args.get_arg( + "vertical" + ): + print(label, end="") + + self.print_row( + values[j], + int(num_blocks[j]), + val_min, + color, + label, + tail, + ) + + if not self.args.get_arg("label_before") and not self.args.get_arg( + "vertical" + ): + print(tail) diff --git a/termgraph/termgraph.py b/termgraph/termgraph.py index 2d55764..6ac7b5e 100755 --- a/termgraph/termgraph.py +++ b/termgraph/termgraph.py @@ -32,6 +32,7 @@ } DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +UNITS = ["", "K", "M", "B", "T"] DELIM = "," TICK = "▇" SM_TICK = "▏" @@ -65,6 +66,11 @@ def init_args() -> Dict: parser.add_argument( "--no-values", action="store_true", help="Do not print the values at end" ) + parser.add_argument( + "--space-between", + action="store_true", + help="Print a new line after every field", + ) parser.add_argument("--color", nargs="*", help="Graph bar color( s )") parser.add_argument("--vertical", action="store_true", help="Vertical graph") parser.add_argument("--stacked", action="store_true", help="Stacked bar graph") @@ -173,12 +179,35 @@ def normalize(data: List, width: int) -> List: def find_max_label_length(labels: List) -> int: """Return the maximum length for the labels.""" - length = 0 - for i in range(len(labels)): - if len(labels[i]) > length: - length = len(labels[i]) + return max([len(label) for label in labels]) + + +def cvt_to_readable(num): + """Return the number in a human readable format + + Eg: + 125000 -> 125.0K + 12550 -> 12.55K + 19561100 -> 19.561M + """ + + if num != 0: + neg = num < 0 + num = abs(num) + + # Find the degree of the number like if it is in thousands or millions, etc. + index = math.floor(math.log(num) / math.log(1000)) + + # Converts the number to the human readable format and returns it. + newNum = round(num / (1000 ** index), 3) + newNum *= -1 if neg else 1 + degree = UNITS[index] + + else: + newNum = 0 + degree = UNITS[0] - return length + return (newNum, degree) def hist_rows(data: List, args: Dict, colors: List): @@ -268,6 +297,9 @@ def horiz_rows( values = data[i] num_blocks = normal_dat[i] + if args.get("space_between") and i != 0: + print() + for j in range(len(values)): # In Multiple series graph 1st category has label at the beginning, # whereas the rest categories have only spaces. @@ -275,21 +307,22 @@ def horiz_rows( len_label = len(label) label = " " * len_label if args.get("label_before"): - fmt = "{}{}" + fmt = "{}{}{}" else: - fmt = " {}{}" + fmt = " {}{}{}" if args["no_values"]: tail = args["suffix"] else: - tail = fmt.format(args["format"].format(values[j]), args["suffix"]) + val, deg = cvt_to_readable(values[j]) + tail = fmt.format(args["format"].format(val), deg, args["suffix"]) if colors: color = colors[j] else: color = None - if doprint and not args["vertical"]: + if not args.get("label_before") and not args.get("vertical"): print(label, end="") yield ( @@ -299,10 +332,10 @@ def horiz_rows( color, label, tail, - not doprint and not args["vertical"], + args.get("label_before") and not args.get("vertical"), ) - if doprint and not args["vertical"]: + if not args.get("label_before") and not args.get("vertical"): print(tail) @@ -317,7 +350,6 @@ def print_row( doprint: bool = False, ): """A method to print a row for a horizontal graphs. - i.e: 1: ▇▇ 2 2: ▇▇▇ 3 @@ -338,9 +370,6 @@ def print_row( sys.stdout.write(SM_TICK) else: if color: - sys.stdout.write( - "\033[{color}m".format(color=color) - ) # Start to write colorized. sys.stdout.write(f"\033[{color}m") # Start to write colorized. for _ in range(num_blocks): sys.stdout.write(TICK) @@ -371,7 +400,11 @@ def stacked_graph( else: label = "{:<{x}}: ".format(labels[i], x=find_max_label_length(labels)) + if args.get("space_between") and i != 0: + print() + print(label, end="") + values = data[i] num_blocks = normal_data[i] @@ -484,6 +517,7 @@ def chart(colors: List, data: List, args: Dict, labels: List) -> None: vertic = vertically(row[0], row[1], row[2], row[3], args=args) else: print_row(*row) + print("\n") # The above gathers data for vertical and does not print # the final print happens at once here @@ -501,6 +535,7 @@ def chart(colors: List, data: List, args: Dict, labels: List) -> None: for row in hist_rows(data, args, colors): print_row(*row) + print() return # One category/Multiple series graph with same scale @@ -508,9 +543,7 @@ def chart(colors: List, data: List, args: Dict, labels: List) -> None: if not args["stacked"]: normal_dat = normalize(data, args["width"]) sys.stdout.write("\033[0m") # no color - for row in horiz_rows( - labels, data, normal_dat, args, colors, not args.get("label_before") - ): + for row in horiz_rows(labels, data, normal_dat, args, colors): if not args["vertical"]: print_row(*row) else: @@ -619,6 +652,7 @@ def read_data(args: Dict) -> Tuple[List, List, List, List]: labels = ['2001', '2002', '2003', ...] categories = ['boys', 'girls'] data = [ [20.4, 40.5], [30.7, 100.0], ...]""" + filename = args["filename"] stdin = filename == "-" @@ -629,7 +663,9 @@ def read_data(args: Dict) -> Tuple[List, List, List, List]: if args["title"]: print("# " + args["title"] + "\n") - categories, labels, data, colors = ([] for i in range(4)) + categories, labels, data, colors = ([] for _ in range(4)) + + f = None try: f = sys.stdin if stdin else open(filename, "r") @@ -655,10 +691,19 @@ def read_data(args: Dict) -> Tuple[List, List, List, List]: data_points.append(float(cols[i].strip())) data.append(data_points) + except FileNotFoundError: + print( + ">> Error: The specified file [{fname}] does not exist.".format( + fname=filename + ) + ) + sys.exit() except IOError: print("An IOError has occurred!") + sys.exit() finally: - f.close() + if f is not None: + f.close() # Check that all data are valid. (i.e. There are no missing values.) colors = check_data(labels, data, args) diff --git a/termgraph/utils.py b/termgraph/utils.py new file mode 100644 index 0000000..b25f0fb --- /dev/null +++ b/termgraph/utils.py @@ -0,0 +1,22 @@ +import math + +UNITS = ["", "K", "M", "B", "T"] + + +def cvt_to_readable(num): + """Return the number in a human readable format + + Eg: + 125000 -> 125.0K + 12550 -> 12.55K + 19561100 -> 19.561M + """ + + # Find the degree of the number like if it is in thousands or millions, etc. + index = int(math.log(num) / math.log(1000)) + + # Converts the number to the human readable format and returns it. + newNum = round(num / (1000 ** index), 3) + degree = UNITS[index] + + return (newNum, degree) diff --git a/tests/test_termgraph.py b/tests/test_termgraph.py index 27a2b90..79d9a9a 100644 --- a/tests/test_termgraph.py +++ b/tests/test_termgraph.py @@ -116,13 +116,13 @@ def test_horiz_rows_yields_correct_values(): rows.append(row) assert rows == [ - (183.32, 17, 1.0, None, "2007: ", " 183.32", False), - (231.23, 22, 1.0, None, "2008: ", " 231.23", False), - (16.43, 1, 1.0, None, "2009: ", " 16.43", False), - (50.21, 4, 1.0, None, "2010: ", " 50.21", False), - (508.97, 50, 1.0, None, "2011: ", " 508.97", False), - (212.05, 20, 1.0, None, "2012: ", " 212.05", False), - (1.0, 0, 1.0, None, "2014: ", " 1.00 ", False), + (183.32, 17, 1.0, None, "2007: ", " 183.32", None), + (231.23, 22, 1.0, None, "2008: ", " 231.23", None), + (16.43, 1, 1.0, None, "2009: ", " 16.43", None), + (50.21, 4, 1.0, None, "2010: ", " 50.21", None), + (508.97, 50, 1.0, None, "2011: ", " 508.97", None), + (212.05, 20, 1.0, None, "2012: ", " 212.05", None), + (1.0, 0, 1.0, None, "2014: ", " 1.00 ", None), ]