Python 2 will retire in thirteen days (see https://pythonclock.org/), there is no reason to keep obsolete checks in the code any longer.
1105 lines
38 KiB
Python
Executable file
1105 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 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_pair"]="."
|
|
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")
|
|
else:
|
|
try:
|
|
for lexer in get_all_lexers():
|
|
try:
|
|
lexers.append(lexer[1][0])
|
|
except IndexError:
|
|
logging.warning("cannot load lexer: %s" % lexer[1][0])
|
|
pass
|
|
except:
|
|
logging.warning("error while executing the pygment module, syntax coloring is not available")
|
|
|
|
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,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_pair=context["sep_pair"]):
|
|
"""
|
|
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_pair,
|
|
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_pair)
|
|
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_pair)
|
|
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 )
|
|
|
|
# The user can change the "colormap" variable to its favorite one before calling colorin.
|
|
elif color == "colormap":
|
|
# "default" should have been set to the user-defined colormap.
|
|
color,color_code = color_map("default")
|
|
|
|
# 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":
|
|
background_code = ""
|
|
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.
|
|
"""
|
|
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("-e", "--sep-list", metavar="CHAR", default=",", type=str,
|
|
help="Use this character as a separator for list of colors/resources/numbers (instead of comma).")
|
|
|
|
parser.add_argument("-E", "--sep-pair", 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_pair
|
|
|
|
|
|
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_pair \
|
|
= _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 #
|
|
##################
|
|
|
|
context["sep_list"] = sep_list
|
|
logging.debug("Color list separator: '%s'" % context["sep_list"])
|
|
context["sep_pair"] = sep_pair
|
|
logging.debug("Color pair separator: '%s'" % context["sep_pair"])
|
|
|
|
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 debug:
|
|
# setting = pprint.pformat(context, depth=2)
|
|
# logging.debug(setting)
|
|
|
|
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"] )
|
|
|