colout/colout/colout.py
2018-02-27 16:09:26 +01:00

1111 lines
38 KiB
Python
Executable file

#!/usr/bin/env python3
#encoding: utf-8
# Color Up Arbitrary Command Output
# Licensed under the GPL version 3
# 2012 (c) nojhan <nojhan@nojhan.net>
import os
import re
import six
import sys
import copy
import glob
import math
import pprint
import random
import signal
import string
import hashlib
import logging
import argparse
import importlib
import functools
# set the SIGPIPE handler to kill the program instead of
# ending in a write error when a broken pipe occurs
signal.signal( signal.SIGPIPE, signal.SIG_DFL )
###############################################################################
# Global variable(s)
###############################################################################
context = {}
debug = False
# Available styles
context["styles"] = {
"normal": 0, "bold": 1, "faint": 2, "italic": 3, "underline": 4,
"blink": 5, "rapid_blink": 6,
"reverse": 7, "conceal": 8
}
# Available color names in 8-colors mode.
eight_colors = ["black","red","green","yellow","blue","magenta","cyan","white"]
# Given in that order, the ASCII code is the index.
eight_color_codes = {n:i for i,n in enumerate(eight_colors)}
# One can add synonyms.
eight_color_codes["orange"] = eight_color_codes["yellow"]
eight_color_codes["purple"] = eight_color_codes["magenta"]
# Foreground colors has a special "none" item.
# Note: use copy to avoid having the same reference over fore/background.
context["colors"] = copy.copy(eight_color_codes)
context["colors"]["none"] = -1
# Background has the same colors than foreground, but without the none code.
context["backgrounds"] = copy.copy(eight_color_codes)
context["themes"] = {}
# pre-defined colormaps
# 8-colors mode should start with a lower-case letter (and can contains either named or indexed colors)
# 256-colors mode should start with an upper-case letter (and should contains indexed colors)
context["colormaps"] = {
# Rainbows
"rainbow" : ["magenta", "blue", "cyan", "green", "yellow", "red"],
"Rainbow" : [92, 93, 57, 21, 27, 33, 39, 45, 51, 50, 49, 48, 47, 46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196],
# From magenta to red, with white in the middle
"spectrum" : ["magenta", "blue", "cyan", "white", "green", "yellow", "red"],
"Spectrum" : [91, 92, 56, 57, 21, 27, 26, 32, 31, 37, 36, 35, 41, 40, 41, 77, 83, 84, 120, 121, 157, 194, 231, 254, 255, 231, 230, 229, 228, 227, 226, 220, 214, 208, 202, 196],
# All the colors are available for the default `random` special
"random" : context["colors"],
"Random" : list(range(256))
} # colormaps
context["colormaps"]["scale"] = context["colormaps"]["spectrum"]
context["colormaps"]["Scale"] = context["colormaps"]["Spectrum"]
context["colormaps"]["hash"] = context["colormaps"]["rainbow"]
context["colormaps"]["Hash"] = context["colormaps"]["Rainbow"]
context["colormaps"]["default"] = context["colormaps"]["spectrum"]
context["colormaps"]["Default"] = context["colormaps"]["Spectrum"]
context["user_defined_colormaps"] = False
context["colormap_idx"] = 0
context["scale"] = (0,100)
context["lexers"] = []
# Character use as a delimiter
# between foreground and background.
context["sep_back"]="."
context["sep_list"]=","
class UnknownColor(Exception):
pass
class DuplicatedPalette(Exception):
pass
class DuplicatedTheme(Exception):
pass
class MixedModes(Exception):
pass
###############################################################################
# Ressource parsing helpers
###############################################################################
def make_colormap( colors, sep_list = context["sep_list"] ):
cmap = colors.split(sep_list)
# Check unicity of mode.
modes = [mode(c) for c in cmap]
if len(uniq(modes)) > 1:
# Format a list of color:mode, for error display.
raise MixedModes(", ".join(["%s:%s" % cm for cm in zip(cmap,modes)]))
return cmap
def set_special_colormaps( cmap, sep_list = context["sep_list"] ):
"""Change all the special colors to a single colormap (which must be a list of colors)."""
global context
context["colormaps"]["scale"] = cmap
context["colormaps"]["Scale"] = cmap
context["colormaps"]["hash"] = cmap
context["colormaps"]["Hash"] = cmap
context["colormaps"]["default"] = cmap
context["colormaps"]["Default"] = cmap
context["colormaps"]["random"] = cmap
context["colormaps"]["Random"] = cmap
context["user_defined_colormaps"] = True
logging.debug("user-defined special colormap: %s" % sep_list.join([str(i) for i in cmap]) )
def parse_gimp_palette( filename ):
"""
Parse the given filename as a GIMP palette (.gpl)
Return the filename (without path and extension) and a list of ordered
colors.
Generally, the colors are RGB triplets, thus this function returns:
(name, [ [R0,G0,B0], [R1,G1,B1], ... , [RN,GN,BN] ])
"""
logging.debug("parse GIMP palette file: %s" % filename)
fd = open(filename)
# remove path and extension, only keep the file name itself
name = os.path.splitext( os.path.basename(filename ))[0]
# The first .gpl line is a header
assert( fd.readline().strip() == "GIMP Palette" )
# Then the full name of the palette
long_name = fd.readline().strip()
# Then the columns number.
# split on colon, take the second argument as an int
line = fd.readline()
if "Columns:" in line:
columns = int( line.strip().split(":")[1].strip() )
lines = fd.readlines()
else:
columns=3
lines = [line] + fd.readlines()
# Then the colors themselves.
palette = []
for line in lines:
# skip lines with only a comment
if re.match("^\s*#.*$", line ):
continue
# decode the columns-ths codes. Generally [R G B] followed by a comment
colors = [ int(c) for c in line.split()[:columns] ]
palette.append( colors )
logging.debug("parsed %i RGB colors from palette %s" % (len(palette), name) )
return name,palette
def uniq( lst ):
"""Build a list with uniques consecutive elements in the argument.
>>> uniq([1,1,2,2,2,3])
[1,2,3]
>>> uniq([0,1,1,2,3,3,3])
[0,1,2,3]
"""
assert( len(lst) > 0 )
uniq = [ lst[0] ]
for i in range(1,len(lst)):
if lst[i] != lst[i-1]:
uniq.append(lst[i])
return uniq
def rgb_to_ansi( r, g, b ):
"""Convert a RGB color to its closest 256-colors ANSI index"""
# Range limits for the *colored* section of ANSI,
# this does not include the *gray* section.
ansi_min = 16
ansi_max = 234
# ansi_max is the higher possible RGB value for ANSI *colors*
# limit RGB values to ansi_max
red,green,blue = tuple([ansi_max if c>ansi_max else c for c in (r,g,b)])
offset = 42.5
is_gray = True
while is_gray:
if red < offset or green < offset or blue < offset:
all_gray = red < offset and green < offset and blue < offset
is_gray = False
offset += 42.5
if all_gray:
val = ansi_max + round( (red + green + blue)/33.0 )
res = int(val)
else:
val = ansi_min
for color,modulo in zip( [red, green, blue], [6*6, 6, 1] ):
val += round(6.0 * (color / 256.0)) * modulo
res = int(val)
return res
def hex_to_rgb(h):
assert( h[0] == "#" )
h = h.lstrip('#')
lh = len(h)
return tuple( int(h[i:i+lh//3], 16) for i in range(0, lh, lh//3) )
###############################################################################
# Load available extern resources
###############################################################################
def load_themes( themes_dir):
global context
logging.debug("search for themes in: %s" % themes_dir)
os.chdir( themes_dir )
sys.path.append( themes_dir )
# load available themes
for f in glob.iglob("colout_*.py"):
module = ".".join(f.split(".")[:-1]) # remove extension
name = "_".join(module.split("_")[1:]) # remove the prefix
if name in context["themes"]:
raise DuplicatedTheme(name)
logging.debug("load theme %s" % name)
context["themes"][name] = importlib.import_module(module)
def load_palettes( palettes_dir, ignore_duplicates = True ):
global context
logging.debug("search for palettes in: %s" % palettes_dir)
os.chdir( palettes_dir )
# load available colormaps (GIMP palettes format)
for p in glob.iglob("*.gpl"):
try:
name,palette = parse_gimp_palette(p)
except Exception as e:
logging.warning("error while parsing palette %s: %s" % ( p,e ) )
continue
if name in context["colormaps"]:
if ignore_duplicates:
logging.warning("ignore this duplicated palette name: %s" % name)
else:
raise DuplicatedPalette(name)
# Convert the palette to ANSI
ansi_palette = [ rgb_to_ansi(r,g,b) for r,g,b in palette ]
# Compress it so that there isn't two consecutive identical colors
compressed = uniq(ansi_palette)
logging.debug("load %i ANSI colors in palette %s: %s" % (len(compressed), name, compressed))
context["colormaps"][name] = compressed
def load_lexers():
global context
# load available pygments lexers
lexers = []
try:
global get_lexer_by_name
from pygments.lexers import get_lexer_by_name
global highlight
from pygments import highlight
global Terminal256Formatter
from pygments.formatters import Terminal256Formatter
global TerminalFormatter
from pygments.formatters import TerminalFormatter
from pygments.lexers import get_all_lexers
except ImportError:
logging.warning("the pygments module has not been found, syntax coloring is not available")
pass
else:
for lexer in get_all_lexers():
try:
lexers.append(lexer[1][0])
except IndexError:
logging.warning("cannot load lexer: %s" % lexer[1][0])
pass
lexers.sort()
logging.debug("loaded %i lexers: %s" % (len(lexers), ", ".join(lexers)))
context["lexers"] = lexers
def load_resources( themes_dir, palettes_dir ):
load_themes( themes_dir )
load_palettes( palettes_dir )
load_lexers()
###############################################################################
# Library
###############################################################################
def mode( color ):
global context
if type(color) is int:
if 0 <= color and color <= 255 :
return 256
else:
raise UnknownColor(color)
elif color in context["colors"]:
return 8
elif color in context["colormaps"].keys():
if color[0].islower():
return 8
elif color[0].isupper():
return 256
elif color.lower() in ("scale","hash","random") or color.lower() in context["lexers"]:
if color[0].islower():
return 8
elif color[0].isupper():
return 256
elif color[0] == "#":
return 256
elif color.isdigit() and (0 <= int(color) and int(color) <= 255) :
return 256
else:
raise UnknownColor(color)
def next_in_map( name ):
global context
# loop over indices in colormap
return (context["colormap_idx"]+1) % len(context["colormaps"][name])
def color_random( color ):
global context
m = mode(color)
if m == 8:
color_name = random.choice(list(context["colormaps"]["random"]))
color_code = context["colors"][color_name]
color_code = str(30 + color_code)
elif m == 256:
color_nb = random.choice(context["colormaps"]["Random"])
color_code = str(color_nb)
return color_code
def color_in_colormaps( color ):
global context
m = mode(color)
if m == 8:
c = context["colormaps"][color][context["colormap_idx"]]
if c.isdigit():
color_code = str(30 + c)
else:
color_code = str(30 + context["colors"][c])
else:
color_nb = context["colormaps"][color][context["colormap_idx"]]
color_code = str( color_nb )
context["colormap_idx"] = next_in_map(color)
return color_code
def color_scale( name, text ):
# filter out everything that does not seem to be necessary to interpret the string as a number
# this permits to transform "[ 95%]" to "95" before number conversion,
# and thus allows to color a group larger than the matched number
chars_in_numbers = "-+.,e/*"
allowed = string.digits + chars_in_numbers
nb = "".join([i for i in filter(allowed.__contains__, text)])
# interpret as decimal
# First, try with the babel module, if available
# if not, use python itself,
# if thoses fails, try to `eval` the string
# (this allow strings like "1/2+0.9*2")
f = None
try:
# babel is a specialized module
import babel.numbers as bn
try:
f = float(bn.parse_decimal(nb))
except bn.NumberFormatError:
pass
except ImportError:
try:
f = float(nb)
except ValueError:
pass
if f is not None:
# normalize with scale if it's a number
f = (f - context["scale"][0]) / (context["scale"][1]-context["scale"][0])
else:
# interpret as float between 0 and 1 otherwise
f = eval(nb)
# if out of scale, do not color
if f < 0 or f > 1:
return None
# normalize and scale over the nb of colors in cmap
colormap = context["colormaps"][name]
i = int( math.ceil( f * (len(colormap)-1) ) )
color = colormap[i]
# infer mode from the color in the colormap
m = mode(color)
if m == 8:
color_code = str(30 + context["colors"][color])
else:
color_code = str(color)
return color_code
def color_hash( name, text ):
hasher = hashlib.md5()
hasher.update(text.encode('utf-8'))
hash = hasher.hexdigest()
f = float(functools.reduce(lambda x, y: x+ord(y), hash, 0) % 101)
# normalize and scale over the nb of colors in cmap
colormap = context["colormaps"][name]
i = int( math.ceil( (f - context["scale"][0]) / (context["scale"][1]-context["scale"][0]) * (len(colormap)-1) ) )
color = colormap[i]
# infer mode from the color in the colormap
m = mode(color)
if m == 8:
color_code = str(30 + context["colors"][color])
else:
color_code = str(color)
return color_code
def color_map(name):
global context
# current color
color = context["colormaps"][name][ context["colormap_idx"] ]
m = mode(color)
if m == 8:
color_code = str(30 + context["colors"][color])
else:
color_nb = int(color)
assert( 0 <= color_nb <= 255 )
color_code = str(color_nb)
context["colormap_idx"] = next_in_map(name)
return color_code
def color_lexer( name, style, text ):
lexer = get_lexer_by_name(name.lower())
# Python => 256 colors, python => 8 colors
m = mode(name)
if m == 256:
try:
formatter = Terminal256Formatter(style=style)
except: # style not found
formatter = Terminal256Formatter()
else:
if style not in ("light","dark"):
style = "dark" # dark color scheme by default
formatter = TerminalFormatter(bg=style)
# We should return all but the last character,
# because Pygments adds a newline char.
if not debug:
return highlight(text, lexer, formatter)[:-1]
else:
return "<"+name+">"+ highlight(text, lexer, formatter)[:-1] + "</"+name+">"
def colorin(text, color="red", style="normal", sep_back=context["sep_back"]):
"""
Return the given text, surrounded by the given color ASCII markers.
The given color may be either a single name, encoding the foreground color,
or a pair of names, delimited by the given sep_back,
encoding foreground and background, e.g. "red.blue".
If the given color is a name that exists in available colors,
a 8-colors mode is assumed, else, a 256-colors mode.
The given style must exists in the available styles.
>>> colorin("Fetchez la vache", "red", "bold")
'\x1b[1;31mFetchez la vache\x1b[0m'
>>> colout.colorin("Faites chier la vache", 41, "normal")
'\x1b[0;38;5;41mFaites chier la vache\x1b[0m'
"""
assert( type(color) is str )
global debug
# Special characters.
start = "\033["
stop = "\033[0m"
# Escaped end markers for given color modes
endmarks = {8: ";", 256: ";38;5;"}
color_code = ""
style_code = ""
background_code = ""
style_codes = []
# Convert the style code
if style == "random" or style == "Random":
style = random.choice(list(context["styles"].keys()))
else:
styles = style.split(sep_back)
for astyle in styles:
if astyle in context["styles"]:
style_codes.append(str(context["styles"][astyle]))
style_code = ";".join(style_codes)
color_pair = color.strip().split(sep_back)
color = color_pair[0]
background = color_pair[1] if len(color_pair) == 2 else "none"
if color == "none" and background == "none":
# if no color, style cannot be applied
if not debug:
return text
else:
return "<none>"+text+"</none>"
elif color.lower() == "random":
color_code = color_random( color )
elif color.lower() == "scale": # "scale" or "Scale"
color_code = color_scale( color, text )
# "hash" or "Hash"; useful to randomly but consistently color strings
elif color.lower() == "hash":
color_code = color_hash( color, text )
# Really useful only when using colout as a library
# thus you can change the "colormap" variable to your favorite one before calling colorin
elif color == "colormap":
# "default" should have been set to the user-defined colormap.
color_code = color_map("default")
# Use the first of the user-defined colormap to detect the mode,
# thus set `color`, to be used by `mode` below.
color = context["colormaps"]["default"][0]
# Registered colormaps should be tested after special colors,
# because special tags are also registered as colormaps,
# but do not have the same simple behavior.
elif color in context["colormaps"].keys():
color_code = color_in_colormaps( color )
# 8 colors modes
elif color in context["colors"]:
color_code = str(30 + context["colors"][color])
# hexadecimal color
elif color[0] == "#":
color_nb = rgb_to_ansi(*hex_to_rgb(color))
assert(0 <= color_nb <= 255)
color_code = str(color_nb)
# 256 colors mode
elif color.isdigit():
color_nb = int(color)
assert(0 <= color_nb <= 255)
color_code = str(color_nb)
# programming language
elif color.lower() in context["lexers"]:
# bypass color encoding and return text colored by the lexer
return color_lexer(color,style,text)
# unrecognized
else:
raise UnknownColor(color)
m = mode(color)
if background in context["backgrounds"] and m == 8:
background_code = endmarks[m] + str(40 + context["backgrounds"][background])
elif background == "none":
pass
else:
raise UnknownColor(background)
if color_code is not None:
if not debug:
return start + style_code + endmarks[m] + color_code + background_code + "m" + text + stop
else:
return start + style_code + endmarks[m] + color_code + background_code + "m" \
+ "<color name=" + str(color) \
+ " code=" + color_code \
+ " style=" + str(style) \
+ " stylecode=" + style_code \
+ " background=" + str(background) \
+ " backgroundcode=" + background_code.strip(endmarks[m]) \
+ " mode=" + str(m) \
+ ">" \
+ text + "</color>" + stop
else:
if not debug:
return text
else:
return "<none>" + text + "</none>"
def colorout(text, match, prev_end, color="red", style="normal", group=0):
"""
Build the text from the previous re.match to the current one,
coloring up the matching characters.
"""
start = match.start(group)
colored_text = text[prev_end:start]
end = match.end(group)
colored_text += colorin(text[start:end], color, style)
return colored_text, end
def colorup(text, pattern, color="red", style="normal", on_groups=False, sep_list=context["sep_list"]):
"""
Color up every characters that match the given regexp patterns.
If groups are specified, only color up them and not the whole pattern.
Colors and styles may be specified as a list of comma-separated values,
in which case the different matching groups may be formatted differently.
If there is less colors/styles than groups, the last format is used
for the additional groups.
>>> colorup("Fetchez la vache", "vache", "red", "bold")
'Fetchez la \x1b[1;31mvache\x1b[0m'
>>> colorup("Faites chier la vache", "[Fv]a", "red", "bold")
'\x1b[1;31mFa\x1b[0mites chier la \x1b[1;31mva\x1b[0mche'
>>> colorup("Faites Chier la Vache", "[A-Z](\S+)\s", "red", "bold")
'F\x1b[1;31maites\x1b[0m C\x1b[1;31mhier\x1b[0m la Vache'
>>> colorup("Faites Chier la Vache", "([A-Z])(\S+)\s", "red,green", "bold")
'\x1b[1;31mF\x1b[0m\x1b[1;32maites\x1b[0m \x1b[1;31mC\x1b[0m\x1b[1;32mhier\x1b[0m la Vache'
>>> colorup("Faites Chier la Vache", "([A-Z])(\S+)\s", "green")
'\x1b[0;32mF\x1b[0m\x1b[0;32maites\x1b[0m \x1b[0;32mC\x1b[0m\x1b[0;32mhier\x1b[0m la Vache'
>>> colorup("Faites Chier la Vache", "([A-Z])(\S+)\s", "blue", "bold,italic")
'\x1b[1;34mF\x1b[0m\x1b[3;34maites\x1b[0m \x1b[1;34mC\x1b[0m\x1b[3;34mhier\x1b[0m la Vache'
"""
global context
global debug
if not debug:
regex = re.compile(pattern)
else:
regex = re.compile(pattern, re.DEBUG)
# Prepare the colored text.
colored_text = ""
end = 0
for match in regex.finditer(text):
# If no groups are specified
if not match.groups():
# Color the previous partial line,
partial, end = colorout(text, match, end, color, style)
# add it to the final text.
colored_text += partial
else:
nb_groups = len(match.groups())
# Build a list of colors that match the number of grouped,
# if there is not enough colors, duplicate the last one.
colors_l = color.split(sep_list)
group_colors = colors_l + [colors_l[-1]] * (nb_groups - len(colors_l))
# Same for styles
styles_l = style.split(sep_list)
group_styles = styles_l + [styles_l[-1]] * (nb_groups - len(styles_l))
# If we want to iterate colormaps on groups instead of patterns
if on_groups:
# Reset the counter at the beginning of each match
context["colormap_idx"] = 0
# For each group index.
# Note that match.groups returns a tuple (thus being indexed in [0,n[),
# but that match.start(0) refers to the whole match, the groups being indexed in [1,n].
# Thus, we need to range in [1,n+1[.
for group in range(1, nb_groups+1):
# If a group didn't match, there's nothing to color
if match.group(group) is not None:
partial, end = colorout(text, match, end, group_colors[group-1], group_styles[group-1], group)
colored_text += partial
# Append the remaining part of the text, if any.
colored_text += text[end:]
return colored_text
###########
# Helpers #
###########
def colortheme(item, theme):
"""
Take a list of list of args to colorup, and color the given item with sequential calls to colorup.
Used to read themes, which can be something like:
[ [ pattern, colors, styles ], [ pattern ], [ pattern, colors ] ]
"""
# logging.debug("use a theme with %i arguments" % len(theme))
for args in theme:
item = colorup(item, *args)
return item
def write(colored, stream = sys.stdout):
"""
Write "colored" on sys.stdout, then flush.
"""
if six.PY2: # If Python 2.x: force unicode
if isinstance(colored, unicode):
colored = colored.encode('utf-8')
try:
stream.write(colored)
stream.flush()
# Silently handle broken pipes
except IOError:
try:
stream.close()
except IOError:
pass
def map_write( stream_in, stream_out, function, *args ):
"""
Read the given file-like object as a non-blocking stream
and call the function on each item (line),
with the given extra arguments.
A call to "map_write(sys.stdin, colorup, pattern, colors)" will translate to the
non-blocking equivalent of:
for item in sys.stdin.readlines():
write( colorup( item, pattern, colors ) )
"""
while True:
try:
item = stream_in.readline()
except UnicodeDecodeError:
continue
except KeyboardInterrupt:
break
if not item:
break
write( function(item, *args), stream_out )
def colorgen(stream, pattern, color="red", style="normal", on_groups=False, sep_list=context["sep_list"]):
"""
A generator that colors the items given in an iterable input.
>>> import math
>>> list(colorgen([str(i) for i in [math.pi,math.e]],"1","red"))
['3.\x1b[0;31m1\x1b[0m4\x1b[0;31m1\x1b[0m59265359',
'2.7\x1b[0;31m1\x1b[0m828\x1b[0;31m1\x1b[0m82846']
"""
while True:
try:
item = stream.readline()
except KeyboardInterrupt:
break
if not item:
break
yield colorup(item, pattern, color, style, on_groups, sep_list)
######################
# Command line tools #
######################
def _args_parse(argv, usage=""):
"""
Parse command line arguments with the argparse library.
Returns a tuple of (pattern,color,style,on_stderr).
"""
parser = argparse.ArgumentParser(
description=usage)
parser.add_argument("pattern", metavar="REGEX", type=str, nargs=1,
help="A regular expression")
pygments_warn=" You can use a language name to activate syntax coloring (see `-r all` for a list)."
try:
import pygments
except ImportError:
pygments_warn=" (WARNING: python3-pygments is not available, \
install it if you want to be able to use syntax coloring)"
parser.add_argument("color", metavar="COLOR", type=str, nargs='?',
default="red",
help="A number in [0…255], a color name, a colormap name, \
a palette or a comma-separated list of those values." + pygments_warn)
parser.add_argument("style", metavar="STYLE", type=str, nargs='?',
default="bold",
help="One of the available styles or a comma-separated list of styles.")
parser.add_argument("-g", "--groups", action="store_true",
help="For color maps (random, rainbow, etc.), iterate over matching groups \
in the pattern instead of over patterns")
parser.add_argument("-c", "--colormap", action="store_true",
help="Interpret the given COLOR comma-separated list of colors as a colormap \
(cycle the colors at each match)")
babel_warn=" (numbers will be parsed according to your locale)"
try:
# babel is a specialized module
import babel.numbers
except ImportError:
babel_warn=" (WARNING: python3-babel is not available, install it \
if you want to be able to parse numbers according to your locale)"
parser.add_argument("-l", "--scale", metavar="SCALE",
help="When using the 'scale' colormap, parse matches as decimal numbers \
and apply the rainbow colormap linearly between the given SCALE=min,max" + babel_warn)
parser.add_argument("-a", "--all", action="store_true",
help="Color the whole input at once instead of line per line \
(really useful for coloring a source code file with strings \
on multiple lines).")
parser.add_argument("-t", "--theme", action="store_true",
help="Interpret REGEX as a theme.")
parser.add_argument("-T", "--themes-dir", metavar="DIR", action="append",
help="Search for additional themes (colout_*.py files) in the given directory")
parser.add_argument("-P", "--palettes-dir", metavar="DIR", action="append",
help="Search for additional palettes (*.gpl files) in the given directory")
parser.add_argument("-d", "--default", metavar="COLORMAP", default=None,
help="When using special colormaps (`random`, `scale` or `hash`), use this COLORMAP. \
This can be either one of the available colormaps or a comma-separated list of colors. \
WARNING: be sure to specify a default colormap that is compatible with the special colormap's mode \
(8 or 256 colors).")
# This normally should be an option with an argument, but this would end in an error,
# as no regexp is supposed to be passed after calling this option,
# we use it as the argument to this option.
# The only drawback is that the help message lacks a metavar...
parser.add_argument("-r", "--resources", action="store_true",
help="Print the names of available resources. Use a comma-separated list of resources names \
(styles, colors, special, themes, palettes, colormaps or lexers), \
use 'all' to print everything.")
parser.add_argument("-s", "--source", action="store_true",
help="Interpret REGEX as a source code readable by the Pygments library. \
If the first letter of PATTERN is upper case, use the 256 colors mode, \
if it is lower case, use the 8 colors mode. \
Interpret COLOR as a Pygments style." + pygments_warn)
parser.add_argument("-m", "--sep-list", metavar="CHAR", default=",", type=str,
help="Use this character as a separator for list of colors (instead of comma).")
parser.add_argument("-b", "--sep-back", metavar="CHAR", default=".", type=str,
help="Use this character as a separator for foreground/background pairs (instead of period).")
parser.add_argument("--debug", action="store_true",
help="Debug mode: print what's going on internally, useful if you want to check what features are available.")
args = parser.parse_args()
return args.pattern[0], args.color, args.style, args.groups, \
args.colormap, args.theme, args.source, args.all, args.scale, args.debug, args.resources, args.palettes_dir, \
args.themes_dir, args.default, args.sep_list, args.sep_back
def write_all( as_all, stream_in, stream_out, function, *args ):
"""
If as_all, print function(*args) on the whole stream,
else, print it for each line.
"""
if as_all:
write( function( stream_in.read(), *args ), stream_out )
else:
map_write( stream_in, stream_out, function, *args )
if __name__ == "__main__":
error_codes = {"UnknownColor":1, "DuplicatedPalette":2, "MixedModes":3}
usage = "A regular expression based formatter that color up an arbitrary text stream."
#####################
# Arguments parsing #
#####################
pattern, color, style, on_groups, as_colormap, as_theme, as_source, as_all, myscale, \
debug, resources, palettes_dirs, themes_dirs, default_colormap, sep_list, sep_back \
= _args_parse(sys.argv, usage)
if debug:
lvl = logging.DEBUG
else:
lvl = logging.ERROR
logging.basicConfig(format='[colout] %(levelname)s: %(message)s', level=lvl)
##################
# Load resources #
##################
if debug:
setting = pprint.pformat(context, depth=2)
logging.debug(setting)
context["sep_list"] = sep_list
logging.debug("Color list separator: %s" % context["sep_list"])
context["sep_back"] = sep_back
logging.debug("Color pair separator: %s" % context["sep_back"])
try:
# Search for available resources files (themes, palettes)
# in the same dir as the colout.py script
res_dir = os.path.dirname(os.path.realpath(__file__))
# this must be called before args parsing, because the help can list available resources
load_resources( res_dir, res_dir )
# try additional directories if asked
if palettes_dirs:
for adir in palettes_dirs:
try:
os.chdir( adir )
except OSError as e:
logging.warning("cannot read palettes directory %s, ignore it" % adir)
continue
else:
load_palettes( adir )
if themes_dirs:
for adir in themes_dirs:
try:
os.chdir( adir )
except OSError as e:
logging.warning("cannot read themes directory %s, ignore it" % adir)
continue
else:
load_themes( adir )
except DuplicatedPalette as e:
logging.error( "duplicated palette file name: %s" % e )
sys.exit( error_codes["DuplicatedPalette"] )
if resources:
asked=[r.lower() for r in pattern.split(context["sep_list"])]
def join_sort( l ):
"""
Sort the given list in lexicographical order,
with upper-cases first, then lower cases
join the list with a comma.
>>> join_sort(["a","B","A","b"])
'A, a, B, b'
"""
return ", ".join(sorted(l, key=lambda s: s.lower()+s))
# print("Available resources:")
for res in asked:
if "style" in res or "all" in res:
print("STYLES: %s" % join_sort(context["styles"]) )
if "color" in res or "all" in res:
print("COLORS: %s" % join_sort(context["colors"]) )
if "special" in res or "all" in res:
print("SPECIAL: %s" % join_sort(["random", "Random", "scale", "Scale", "hash", "Hash", "colormap"]) )
if "theme" in res or "all" in res:
if len(context["themes"]) > 0:
print("THEMES: %s" % join_sort(context["themes"].keys()) )
else:
print("NO THEME")
if "colormap" in res or "all" in res:
if len(context["colormaps"]) > 0:
print("COLORMAPS: %s" % join_sort(context["colormaps"]) )
else:
print("NO COLORMAPS")
if "lexer" in res or "all" in res:
if len(context["lexers"]) > 0:
print("SYNTAX COLORING: %s" % join_sort(context["lexers"]) )
else:
print("NO SYNTAX COLORING (check that python3-pygments is installed)")
sys.exit(0) # not an error, we asked for help
############
# Coloring #
############
try:
if myscale:
context["scale"] = tuple([float(i) for i in myscale.split(context["sep_list"])])
logging.debug("user-defined scale: %f,%f" % context["scale"])
# Default color maps
if default_colormap:
if default_colormap not in context["colormaps"]:
cmap = make_colormap(default_colormap,context["sep_list"])
elif default_colormap in context["colormaps"]:
cmap = context["colormaps"][default_colormap]
set_special_colormaps( cmap, context["sep_list"] )
# explicit color map
if as_colormap is True and color not in context["colormaps"]:
context["colormaps"]["Default"] = make_colormap(color,context["sep_list"]) # replace the colormap by the given colors
context["colormaps"]["default"] = make_colormap(color,context["sep_list"]) # replace the colormap by the given colors
color = "colormap" # use the keyword to switch to colormap instead of list of colors
logging.debug("used-defined default colormap: %s" % context["sep_list"].join(context["colormaps"]["Default"]) )
# if theme
if as_theme:
logging.debug( "asked for theme: %s" % pattern )
assert(pattern in context["themes"].keys())
context,theme = context["themes"][pattern].theme(context)
write_all( as_all, sys.stdin, sys.stdout, colortheme, theme )
# if pygments
elif as_source:
logging.debug("asked for lexer: %s" % pattern.lower())
assert(pattern.lower() in context["lexers"])
lexer = get_lexer_by_name(pattern.lower())
# Python => 256 colors, python => 8 colors
ask_256 = pattern[0].isupper()
if ask_256:
logging.debug("256 colors mode")
try:
formatter = Terminal256Formatter(style=color)
except: # style not found
logging.warning("style %s not found, fallback to default style" % color)
formatter = Terminal256Formatter()
else:
logging.debug("8 colors mode")
formatter = TerminalFormatter()
write_all( as_all, sys.stdin, sys.stdout, highlight, lexer, formatter )
# if color
else:
write_all( as_all, sys.stdin, sys.stdout, colorup, pattern, color, style, on_groups, context["sep_list"] )
except UnknownColor as e:
if debug:
import traceback
print(traceback.format_exc())
logging.error("Unknown color: %s (maybe you forgot to install python3-pygments?)" % e )
sys.exit( error_codes["UnknownColor"] )
except MixedModes as e:
logging.error("You cannot mix up color modes when defining your own colormap." \
+ " Check the following 'color:mode' pairs: %s." % e )
sys.exit( error_codes["MixedModes"] )