├── .gitignore ├── LICENSE ├── README.rst ├── colorize.py ├── test_colorize.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .tox/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Steven Fernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | colorize 2 | ======== 3 | 4 | \*nixy filter that adds color to its standard input by rows or columns 5 | 6 | 7 | Example usage 8 | 9 | * output alternate rows in different colors:: 10 | 11 | $ ls -l | colorize.py -a 12 | $ ls -l | colorize.py -a green,blue 13 | 14 | * output each space separated column from stdin in a different color:: 15 | 16 | $ tail -f logfile | colorize.py 17 | $ tail -f logfile | colorize.py -c green,blue,red,yellow 18 | 19 | * output the first 3 space separated columns in different colors and all subsequent text in one color:: 20 | 21 | $ tail -f logfile | colorize.py 3 22 | $ tail -f logfile | colorize.py -c green,blue,red 3 23 | 24 | * output the columns specified by widths in different colors 25 | 26 | :: 27 | 28 | # - The first 10 characters is green, the next 12 in red, followed by space 29 | # separated columns alternating in green and red 30 | $ tail -f logfile | colorize.py -c green:10,red:12 31 | 32 | # - The first 10 characters in green, the next 12 in red, all subsequent text in yellow 33 | $ tail -f logfile | colorize.py -c green:10,red:12,yellow 3 34 | 35 | # - The first 10 characters in the default first color (blue), the next 12 in green, 36 | # the next space separated column in red, the subsequent text in yellow 37 | $ tail -f logfile | colorize.py -c :10,green:12,red,yellow 4 38 | 39 | 40 | * filter the output of tail -f, coloring lines from each file in different color:: 41 | 42 | $ tail -f first.log second.log | colorize.py -t 43 | $ tail -f first.log second.log | colorize.py -t green,yellow 44 | 45 | 46 | Demo 47 | ==== 48 | |demo| 49 | 50 | 51 | Usage Tip 52 | ========= 53 | 54 | If you use `bash`, you can create colorized versions of commands, like:: 55 | 56 | function ctail() { tail $@ | colorize.py -t; } 57 | function cll() { ls -l $@ | colorize.py 8; } 58 | function cvmstat() { vmstat $@ | colorize.py -a red,green; } 59 | 60 | 61 | .. |demo| image:: https://asciinema.org/a/107799.png 62 | :target: https://asciinema.org/a/107799?speed=2 63 | -------------------------------------------------------------------------------- /colorize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2017 Steven Fernandez 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | """ 26 | colorize standard input by rows or columns 27 | """ 28 | 29 | from __future__ import unicode_literals 30 | import argparse 31 | import functools 32 | import io 33 | import re 34 | import sys 35 | 36 | from itertools import cycle 37 | try: 38 | from future_builtins import zip 39 | except ImportError: 40 | pass 41 | 42 | __version__ = "0.4" 43 | 44 | 45 | def _create_color_func(code, bold=True): 46 | def color_func(text): 47 | reset = '\033[0m' 48 | color = '\033[{0}{1}m'.format('1;' if bold else '', code) 49 | return "{color}{text}{reset}".format(**vars()) 50 | return color_func 51 | 52 | 53 | colors = { 54 | # add any colors you might need. 55 | name: _create_color_func(idx) 56 | for idx, name in enumerate( 57 | ('red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'grey', 'white'), 58 | 31 59 | ) 60 | } 61 | 62 | 63 | # XXX compatibility workaround: This exists simply for the tests which 64 | # were based on an earlier scheme where the color functions belonged to 65 | # a class based namespace 66 | class Colors(object): 67 | pass 68 | 69 | 70 | for name, func in colors.items(): 71 | setattr(Colors, name, staticmethod(func)) 72 | 73 | 74 | class HelpFormatterMixin(argparse.RawDescriptionHelpFormatter, 75 | argparse.ArgumentDefaultsHelpFormatter): 76 | pass 77 | 78 | 79 | def split_by_widths(input_string, widths, maxsplit=None): 80 | """Yields `maxsplit` sub-strings split at specified widths or space. 81 | 82 | Yields a list of strings obtained by splitting input string 83 | according to specified widths. If any element in the widths list is 84 | false-y, split the string up to and including the next spaces. 85 | 86 | If maxsplit is not None, input_string will be split into maxsplit 87 | parts (even if specified widths are greater than maxsplit) 88 | 89 | :param string input_string: Input string to split 90 | :param list widths: list of widths to use for splitting input_string 91 | :param int|None maxsplit: Max number of parts to split input_string in 92 | 93 | >>> list(split_by_widths('ABBCCC DDDD EEEEE', [1, 2, 3, None, 0])) 94 | ['A', 'BB', 'CCC', ' DDDD ', 'EEEEE'] 95 | >>> list(split_by_widths('A BB CCC DDDD', [1, 2, 3, 4], maxsplit=2)) 96 | ['A', ' BB CCC DDDD'] 97 | >>> list(split_by_widths('', [2])) 98 | [''] 99 | >>> list(split_by_widths('A', [2])) 100 | ['A'] 101 | >>> list(split_by_widths(' A', [2], 10)) 102 | [' A'] 103 | >>> list(split_by_widths(' A B ', [2], 10)) 104 | [' A', ' B '] 105 | >>> list(split_by_widths(' A B CCC DDD EEE', [2, 1, 0, None], 5)) 106 | [' A', ' ', 'B ', 'CCC ', 'DDD EEE'] 107 | """ 108 | start = 0 109 | widths = widths[:maxsplit-1] if maxsplit else widths 110 | for width in widths: 111 | if width: 112 | substr = input_string[start:start+width] 113 | else: 114 | matches = re.split(r'(\s*\S+\s+)', input_string[start:], maxsplit=1) 115 | substr = ''.join(matches[:2]) if len(matches) > 2 else ''.join(matches) 116 | width = len(substr) 117 | yield substr 118 | start += width 119 | 120 | # finally yield rest of the string, in case all widths were not specified 121 | if start < len(input_string): 122 | yield input_string[start:] 123 | 124 | 125 | def main(args): 126 | color_func = colors.get 127 | supported_colors = sorted(colors.keys()) 128 | 129 | parser = argparse.ArgumentParser(description="Colorize standard input by rows or columns." 130 | " Default mode is to color columns.", 131 | epilog="These colors are supported: %s" % ', '.join( 132 | color_func(name)(name) for name in supported_colors), 133 | formatter_class=HelpFormatterMixin) 134 | 135 | group = parser.add_mutually_exclusive_group() 136 | 137 | group.add_argument('-c', '--column-colors', nargs="?", type=lambda o: o.split(','), 138 | const=",".join(supported_colors), default=",".join(supported_colors), 139 | metavar="color,...", 140 | help=("colors to use for column mode, in the order specified. " 141 | "Column widths can be provided as a suffix separated by a `:`" 142 | " (eg: red:10,blue,green:20...).") 143 | ) 144 | parser.add_argument('-d', '--delimiter', nargs=1, type=str, 145 | help="delimiter to use in column mode instead of whitespace.") 146 | parser.add_argument('max_colors', nargs='?', default=0, type=int, 147 | help="Limit to using these many colors (< {})".format(len(supported_colors))) 148 | 149 | group.add_argument('-a', '--alternate', help="alternate line mode.", nargs="?", type=lambda o: o.split(','), 150 | default=False, const='white,grey', metavar="color,...") 151 | group.add_argument('-t', '--tail', help="tail mode.", nargs="?", type=lambda o: o.split(','), 152 | default=False, const=",".join(supported_colors), metavar="color,...") 153 | 154 | opts = parser.parse_args(args) 155 | 156 | # change stdin and stdout to line buffered mode 157 | stdin = io.open(sys.stdin.fileno(), 'r', 1) 158 | stdout = io.open(sys.stdout.fileno(), 'w', 1) 159 | 160 | pallete = cycle(color_func(name) for name in (opts.alternate or supported_colors)) 161 | if opts.alternate: 162 | # row coloring mode 163 | stdout.writelines(color(line) for color, line in zip(pallete, stdin)) 164 | elif opts.tail: 165 | # tail command output coloring mode 166 | path_to_color = {} # dict to keep track of colors assigned to files 167 | color = next(pallete) 168 | for line in stdin: 169 | if line.startswith('==> ') and line.endswith(' <==\n'): 170 | path = line.split()[1] 171 | # - get the color assigned to this path or set a new one 172 | # if one hasn't been assigned yet 173 | color = path_to_color.setdefault(path, next(pallete)) 174 | stdout.write(color(line)) 175 | else: 176 | # default column coloring mode 177 | column_colors = opts.column_colors or supported_colors 178 | if any(':' in option for option in column_colors): 179 | # - split by width 180 | column_colors, widths = zip( 181 | *( 182 | (color, int(width or 0)) 183 | for opt in column_colors 184 | for color, _, width in [opt.partition(':')] 185 | ) 186 | ) 187 | split_func = functools.partial(split_by_widths, widths=widths, maxsplit=opts.max_colors) 188 | else: 189 | pattern = r'(.+?{0})'.format(opts.delimiter) if opts.delimiter else r'(\S+\s+)' 190 | split_func = functools.partial(re.split, pattern, maxsplit=opts.max_colors) 191 | 192 | for line in stdin: 193 | # - reset the color pallete for each line, also use a next 194 | # default color from supported_colors for any column without 195 | # an explicitly specified color. 196 | default = iter(supported_colors) 197 | pallete = cycle(color_func(name or next(default)) for name in column_colors) 198 | # - split the line into max_split parts and zip(pallete, parts) 199 | for color, word in zip(pallete, filter(None, split_func(line))): 200 | stdout.write(color(word)) 201 | 202 | 203 | if __name__ == '__main__': 204 | try: 205 | main(sys.argv[1:]) 206 | except KeyboardInterrupt: 207 | # avoid printing the traceback on KeyboardInterrupt 208 | pass 209 | -------------------------------------------------------------------------------- /test_colorize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | import doctest 5 | import unittest 6 | from subprocess import Popen, PIPE 7 | 8 | import colorize 9 | 10 | Colors = colorize.Colors 11 | 12 | 13 | class TestColorize(unittest.TestCase): 14 | 15 | def setUp(self): 16 | self.exe = "./colorize.py" 17 | self.input_lines = '\n'.join(["aaa bbb ccc ddd", "AAA BBB CCC DDD", "000 111 222 333"]) 18 | self.proc = None 19 | 20 | def tearDown(self): 21 | self.proc.terminate() 22 | self.proc.wait() 23 | 24 | def check(self, cmdline, expected, input_lines=None): 25 | input_lines = input_lines if input_lines else self.input_lines 26 | self.proc = Popen(cmdline, stdin=PIPE, stdout=PIPE, universal_newlines=True) 27 | self.proc.stdin.write(input_lines) 28 | self.proc.stdin.close() 29 | self.assertEqual(self.proc.stdout.read(), expected) 30 | 31 | def test_default_action(self): 32 | expected = "".join([ 33 | Colors.blue("aaa ") + Colors.cyan("bbb ") + Colors.green("ccc ") + Colors.grey("ddd\n"), 34 | Colors.blue("AAA ") + Colors.cyan("BBB ") + Colors.green("CCC ") + Colors.grey("DDD\n"), 35 | Colors.blue("000 ") + Colors.cyan("111 ") + Colors.green("222 ") + Colors.grey("333") 36 | ]) 37 | self.check([self.exe], expected) 38 | 39 | def test_max_colors(self): 40 | expected = "".join([ 41 | Colors.blue("aaa ") + Colors.cyan("bbb ccc ddd\n"), 42 | Colors.blue("AAA ") + Colors.cyan("BBB CCC DDD\n"), 43 | Colors.blue("000 ") + Colors.cyan("111 222 333") 44 | ]) 45 | self.check([self.exe, "1"], expected) 46 | 47 | def test_custom_column_colors(self): 48 | expected = "".join([ 49 | Colors.red("aaa ") + Colors.green("bbb ") + Colors.blue("ccc ") + Colors.red("ddd\n"), 50 | Colors.red("AAA ") + Colors.green("BBB ") + Colors.blue("CCC ") + Colors.red("DDD\n"), 51 | Colors.red("000 ") + Colors.green("111 ") + Colors.blue("222 ") + Colors.red("333") 52 | ]) 53 | self.check([self.exe, "-c", "red,green,blue"], expected) 54 | 55 | def test_custom_column_colors_and_max_colors(self): 56 | expected = "".join([ 57 | Colors.red("aaa ") + Colors.green("bbb ccc ddd\n"), 58 | Colors.red("AAA ") + Colors.green("BBB CCC DDD\n"), 59 | Colors.red("000 ") + Colors.green("111 222 333") 60 | ]) 61 | self.check([self.exe, "-c", "red,green", "1"], expected) 62 | 63 | def test_only_widths(self): 64 | expected = "".join([ 65 | Colors.blue("aaa bb") + Colors.cyan("b ") + Colors.blue("ccc ddd\n"), 66 | Colors.blue("AAA BB") + Colors.cyan("B ") + Colors.blue("CCC DDD\n"), 67 | Colors.blue("000 11") + Colors.cyan("1 ") + Colors.blue("222 333"), 68 | ]) 69 | self.check([self.exe, "-c", ":6,"], expected) 70 | 71 | def test_width_and_max_colors(self): 72 | expected = "".join([ 73 | Colors.blue("aaa bb") + Colors.cyan("b ccc ddd\n"), 74 | Colors.blue("AAA BB") + Colors.cyan("B CCC DDD\n"), 75 | Colors.blue("000 11") + Colors.cyan("1 222 333"), 76 | ]) 77 | self.check([self.exe, "-c", ":6,", "2"], expected) 78 | 79 | def test_width_and_custom_colors(self): 80 | expected = "".join([ 81 | Colors.blue("aaa bb") + Colors.red("b c") + Colors.yellow("cc ") + Colors.blue("ddd\n"), 82 | Colors.blue("AAA BB") + Colors.red("B C") + Colors.yellow("CC ") + Colors.blue("DDD\n"), 83 | Colors.blue("000 11") + Colors.red("1 2") + Colors.yellow("22 ") + Colors.blue("333"), 84 | ]) 85 | self.check([self.exe, "-c", ":6,red:3,yellow"], expected) 86 | 87 | def test_width_custom_colors_and_max_colors(self): 88 | expected = "".join([ 89 | Colors.blue("aaa bb") + Colors.red("b c") + Colors.yellow("cc ddd\n"), 90 | Colors.blue("AAA BB") + Colors.red("B C") + Colors.yellow("CC DDD\n"), 91 | Colors.blue("000 11") + Colors.red("1 2") + Colors.yellow("22 333"), 92 | ]) 93 | self.check([self.exe, "-c", ":6,red:3,yellow", "3"], expected) 94 | 95 | def test_alternate_mode(self): 96 | expected = "".join([ 97 | Colors.white("aaa bbb ccc ddd\n"), 98 | Colors.grey("AAA BBB CCC DDD\n"), 99 | Colors.white("000 111 222 333") 100 | ]) 101 | self.check([self.exe, "-a"], expected) 102 | 103 | def test_alternate_mode_custom_colors(self): 104 | expected = "".join([ 105 | Colors.red("aaa bbb ccc ddd\n"), 106 | Colors.green("AAA BBB CCC DDD\n"), 107 | Colors.red("000 111 222 333") 108 | ]) 109 | self.check([self.exe, "-a", "red,green"], expected) 110 | 111 | def test_tailf_mode(self): 112 | self.maxDiff = None 113 | input_lines = "\n".join([ 114 | "==> /path/to/first.log <==", 115 | "", 116 | "2015-01-01 00:00:01 [INFO] Something odd happened here", 117 | "2015-01-01 00:00:01 [INFO] Something odd happened here", 118 | "", 119 | "==> /path/to/second.log <==", 120 | "", 121 | "2015-01-01 00:00:01 [INFO] Something odd happened here", 122 | "2015-01-01 00:00:01 [INFO] Something odd happened here", 123 | ]) 124 | expected = "".join([ 125 | Colors.cyan("==> /path/to/first.log <==\n"), 126 | Colors.cyan("\n"), 127 | Colors.cyan("2015-01-01 00:00:01 [INFO] Something odd happened here\n"), 128 | Colors.cyan("2015-01-01 00:00:01 [INFO] Something odd happened here\n"), 129 | Colors.cyan("\n"), 130 | Colors.green("==> /path/to/second.log <==\n"), 131 | Colors.green("\n"), 132 | Colors.green("2015-01-01 00:00:01 [INFO] Something odd happened here\n"), 133 | Colors.green("2015-01-01 00:00:01 [INFO] Something odd happened here"), 134 | ]) 135 | self.check([self.exe, "-t"], expected, input_lines) 136 | 137 | 138 | def load_tests(loader, tests, ignore): 139 | tests.addTests(doctest.DocTestSuite(colorize)) 140 | return tests 141 | 142 | if __name__ == '__main__': 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py33,py34,py35,py36,pypy 3 | skipsdist=True 4 | 5 | [testenv] 6 | deps=pytest 7 | setenv=PYTHONDONTWRITEBYTECODE=1 8 | commands=pytest 9 | --------------------------------------------------------------------------------