"""Color your output to terminal""" import os import re import sys __all__ = ['Color', 'Rule', 'Config'] # Color types, their color codes if it's style, their default reset codes, and # RegEx's for detecting their color type. COLOR_TYPES = { 'fg': { 'reset': '\x1b[39m', 're': re.compile(r'\x1b\[(?:3[0-79]|9[0-7]|38;[0-9;]+)m') }, 'bg': { 'reset': '\x1b[49m', 're': re.compile(r'\x1b\[(?:4[0-79]|10[0-7]|48;[0-9;]+)m') }, 'blink': { 'code': '\x1b[5m', 'reset': '\x1b[25m', 're': re.compile(r'\x1b\[2?5m') }, 'bold': { 'code': '\x1b[1m', 'reset': '\x1b[22m', # Normal intensity 're': re.compile(r'\x1b\[(?:1|2?2)m') # Any intensity type }, 'italic': { 'code': '\x1b[3m', 'reset': '\x1b[23m', 're': re.compile(r'\x1b\[2?3m') }, 'strike': { 'code': '\x1b[9m', 'reset': '\x1b[29m', 're': re.compile(r'\x1b\[2?9m') }, 'underline': { 'code': '\x1b[4m', 'reset': '\x1b[24m', 're': re.compile(r'\x1b\[2?4m') } } # Detect rgb support RGB_SUPPORTED = os.getenv('COLORTERM') in ('truecolor', '24bit') # Enable VT100 processing on stdout (Windows 10.0.10586). At exit, revert back if sys.platform.startswith('win32'): # pragma: no cover import atexit import ctypes import ctypes.wintypes # https://docs.microsoft.com/en-us/windows/console/getstdhandle STDOUT = ctypes.windll.kernel32.GetStdHandle(-11) MODE = ctypes.wintypes.DWORD() # https://docs.microsoft.com/en-us/windows/console/getconsolemode ctypes.windll.kernel32.GetConsoleMode(STDOUT, ctypes.byref(MODE)) ctypes.windll.kernel32.SetConsoleMode(STDOUT, MODE.value | 0x0004) # Restore the old console mode before exiting atexit.register(ctypes.windll.kernel32.SetConsoleMode, STDOUT, MODE.value) # ANSI RGB is supported even on CMD since Windows 10.0.10586 RGB_SUPPORTED = True class Color: """A color that highlights strings for terminals.""" def __init__(self, color, rgb=None): """Constructor. Args: color (str): A string which must contain * one foreground color (hex color prefixed with `f#`), * one background color (hex color prefixed with `b#`), * at least one style (blink, bold, italic, strike, underline), or * a combination of the above, seperated by spaces. Example: `"b#123123 bold"` rgb (bool): Whether the color is meant for RGB-enabled terminals or not. `False` will downscale the RGB colors to xterm-256. `None` will detect support for RGB and fallback to xterm-256. Raises: TypeError: If `color` is not a string. If `rgb` is not a boolean. ValueError: If the format of `color` is invalid. """ self.rgb = rgb self.color = color def __call__(self, function): def wrapped(*args, **kwargs): return self.highlight(function(*args, **kwargs)) return wrapped def __repr__(self): args = [repr(self.color)] if self.rgb is not None: args.append('rgb=' + repr(self.rgb)) return '{}({})'.format(self.__class__.__name__, ', '.join(args)) def __str__(self): return 'Color: ' + self.color @property def color(self): """String that represents the color. When changed, updates [color_code][chromaterm.Color.color_code] and [color_reset][chromaterm.Color.color_reset].""" return self._color @color.setter def color(self, value): if not isinstance(value, str): raise TypeError('color must be a string') value = value.lower().strip() styles = tuple(k for k, v in COLOR_TYPES.items() if v.get('code')) color_re = r'^(((b|f)#[0-9a-f]{6}|' + '|'.join(styles) + r')(\s+|$))+$' color_code = color_reset = '' color_types = [] if not re.search(color_re, value): raise ValueError('invalid color format {}'.format(repr(value))) # Colors for target, hex_code in re.findall(r'(b|f)#([0-9a-f]{6})', value): if target == 'f': target, color_type = '\x1b[38;', 'fg' else: target, color_type = '\x1b[48;', 'bg' if color_type in [x[0] for x in color_types]: raise ValueError( 'color accepts one foreground and one background colors') # Break down hex color to red, green, and blue integers rgb_int = [int(hex_code[i:i + 2], 16) for i in [0, 2, 4]] if self.rgb or (self.rgb is None and RGB_SUPPORTED): target += '2;' color_id = ';'.join([str(x) for x in rgb_int]) else: target += '5;' color_id = str(self.rgb_to_8bit(*rgb_int)) color_code = color_code + target + color_id + 'm' color_reset = COLOR_TYPES[color_type]['reset'] + color_reset color_types.append((color_type, target + color_id + 'm')) # Styles for style in re.findall('|'.join(styles), value): if style in [x[0] for x in color_types]: raise ValueError('color does not accept duplicate styles') color_code = color_code + COLOR_TYPES[style]['code'] color_reset = COLOR_TYPES[style]['reset'] + color_reset color_types.append((style, COLOR_TYPES[style]['code'])) self._color = ' '.join(re.split(r'\s+', value.strip().lower())) self._color_code = color_code self._color_reset = color_reset self._color_types = color_types @property def color_code(self): """ANSI escape sequence that instructs a terminal to color output. Updated when [color][chromaterm.Color.color] is changed.""" return self._color_code @property def color_reset(self): """ANSI escape sequence that instructs a terminal to revert to the default color. Updated when [color][chromaterm.Color.color] is changed.""" return self._color_reset @property def color_types(self): """List of tuples for each color type in this instance and its value. The types correspond to `chromaterm.COLOR_TYPES`. Updated when [color][chromaterm.Color.color] is changed.""" return self._color_types.copy() @property def rgb(self): """Flag for RGB-support. When changed, updates [color][chromaterm.Color.color].""" return self._rgb @rgb.setter def rgb(self, value): if value is not None and not isinstance(value, bool): raise TypeError('rgb must be a boolean') self._rgb = value # Update the color if preset; it won't be during __init__ if hasattr(self, '_color'): self.color = self._color @staticmethod def rgb_to_8bit(_r, _g, _b): """Downscale from 24-bit RGB to 8-bit ANSI.""" def downscale(value, base=6): return int(value / 256 * base) # Use the 24 shades of the grayscale if _r == _g == _b: return 232 + downscale(_r, base=24) return 16 + (36 * downscale(_r)) + (6 * downscale(_g)) + downscale(_b) def highlight(self, data, force=None): """Returns a highlighted string of `data`. Args: data (str): A string to highlight. `__str__` of `data` is called. force (bool): If `True`, the color codes are used when highlighting. If `False`, the color codes will be omitted, there by disabling any highlighting and simply returning back `data`. If `None`, the value is determined with `isatty`. """ if force is None: force = getattr(sys.stdout, 'isatty', lambda: False)() if force is False: return str(data) return self.color_code + str(data) + self.color_reset def print(self, *args, force=None, **kwargs): """A wrapper for the `print` function. It highlights before printing. Args: *args (...): Arguments to be printed. Highlighted before being passed to the `print` function. force (bool): Passed to [highlight][chromaterm.Color.highlight]. **kwargs (x=y): Keyword arguments passed to the `print` function. """ print(*[self.highlight(arg, force=force) for arg in args], **kwargs) class Rule: """A rule that highlights parts of strings which match a regular expression. The regular expression engine used is Python's [re](https://docs.python.org/3/library/re.html).""" def __init__(self, regex, color=None, description=None): """Constructor. Args: regex (str): A regular expression used for matching the input when highlighting. color (chromaterm.Color): A color used to highlight the matched input. This will default to highlighting the entire match (also known as group 0 of the regular expression). This can left to `None` if you intend to use [add_color][chromaterm.Rule.add_color] to manually specify which group in the regular expression should be highlighted. description (str): A description to help identify the rule. Raises: TypeError: If `regex` is not a string. If `color` is not an instance of [chromaterm.Color](../color/). If `description` is not a string. """ self._colors = {} self.regex = regex self.description = description if color: self.color = color def __call__(self, function): def wrapped(*args, **kwargs): return self.highlight(function(*args, **kwargs)) return wrapped def __repr__(self): args = [repr(self.regex.pattern)] if self.color: args.append('color=' + repr(self.color)) if self.description: args.append('description=' + repr(self.description)) return '{}({})'.format(self.__class__.__name__, ', '.join(args)) def __str__(self): if self.description: return 'Rule: ' + self.description return 'Rule: ' + repr(self.regex.pattern) @property def color(self): """Color used for highlight the full match (group 0) of regex.""" return self.colors.get(0) @color.setter def color(self, value): self.add_color(value) @property def colors(self): """Colors of the rule. It is dictionary where the keys are integers corresponding to the groups in [regex][chromaterm.Rule.regex] and the values are instances of [chromaterm.Color](../color/) which are used for highlighting.""" # Return a copy of the dictionary to prevent modification of shallow # values; modifying the content of the values, like colors[0].rgb = True # is fine as it doesn't change the object type. return self._colors.copy() @property def description(self): """Description for the rule.""" return self._description @description.setter def description(self, value): if value is not None and not isinstance(value, str): raise TypeError('description must be a string') self._description = value @property def regex(self): """Regular expression used for matching the input when highlighting.""" return self._regex @regex.setter def regex(self, value): if not isinstance(value, str): raise TypeError('regex must be a string') self._regex = re.compile(value) if isinstance(value, str) else value def add_color(self, color, group=0): """Adds a color to be used when highlighting. The group can be used to limit the parts of the match which are highlighted. Group 0 (the default) will highlight the entire match. If a color already exists for the group, it is overwritten. Args: color (chromaterm.Color): A color for highlighting the matched input. group (int): The regex group to be be highlighted with the color. Raises: TypeError: If `color` is not an instance of [chromaterm.Color](../color/). If `group` is not an integer. ValueError: If `group` does not exist in the regular expression. """ if not isinstance(color, Color): raise TypeError('color must be a chromaterm.Color') if not isinstance(group, int): raise TypeError('group must be an integer') if group > self.regex.groups: raise ValueError('regex only has {} group(s); {} is ' 'invalid'.format(self.regex.groups, group)) self._colors[group] = color # Sort the colors according to the group number to ensure deterministic # highlighting self._colors = {k: self._colors[k] for k in sorted(self._colors)} def remove_color(self, group): """Removes a color from the rule's colors. Args: group (int): The regex group. It is a key in the colors dictionary. Raises: TypeError: If `group` is not an integer. """ if not isinstance(group, int): raise TypeError('group must be an integer') self._colors.pop(group, None) def get_matches(self, data): """Returns a list of tuples, each of which containing a start index, an end index, and the [chromaterm.Color][] object for that match. Only regex groups associated with a color are included. Args: data (str): A string to match regex against. """ if not self.colors: return [] matches = [] for match in self.regex.finditer(data): for group in self.colors: start, end = match.span(group) # Zero-length match or optional group not in the match if start == end: continue matches.append((start, end, self.colors[group])) return matches def highlight(self, data, force=None): """Returns a highlighted string of `data`. The regex of the rule is used along with the colors to highlight the matching parts of the `data`. Args: data (str): A string to highlight. `__str__` of `data` is called. force (bool): If `True`, the color codes are used when highlighting. If `False`, the color codes will be omitted, there by disabling any highlighting and simply returning back `data`. If `None`, the value is determined with `isatty`. """ if force is None: force = getattr(sys.stdout, 'isatty', lambda: False)() if force is False: return str(data) data = str(data) inserts = [] for start, end, color in self.get_matches(data): insert_index = 0 # Arrange the inserts in reverse order (index magic over data) for index, (position, _) in enumerate(inserts): # A rule will never create overlapping (because of re.finditer), # so the start and end insert indexes will always be adjacent if start >= position: insert_index = index break inserts.insert(insert_index, (start, color.color_code)) inserts.insert(insert_index, (end, color.color_reset)) for position, code in inserts: data = data[:position] + code + data[position:] return data def print(self, *args, force=None, **kwargs): """A wrapper for the `print` function. It highlights before printing. Args: *args (...): Arguments to be printed. Highlighted before being passed to the `print` function. force (bool): Passed to [highlight][chromaterm.Rule.highlight]. **kwargs (x=y): Keyword arguments passed to the `print` function. """ print(*[self.highlight(arg, force=force) for arg in args], **kwargs) class Config: """An aggregation of multiple rules which provides improved highlighting by performing the regular expression matching of the rules before any colors are added to the string.""" def __init__(self): """Constructor.""" self._reset_codes = {k: COLOR_TYPES[k]['reset'] for k in COLOR_TYPES} self._rules = [] def __call__(self, function): def wrapped(*args, **kwargs): return self.highlight(function(*args, **kwargs)) return wrapped def __repr__(self): return '{}()'.format(self.__class__.__name__) def __str__(self): count = len(self.rules) return 'Config: {} rule{}'.format(count, '' if count == 1 else 's') @property def rules(self): """List of [chromaterm.Rule](../rule/) objects used during highlighting.""" # Return a copy of the list to prevent modification, like extending it. # Modifying the content of the items is fine as it doesn't change the # object type. return self._rules.copy() def add_rule(self, rule): """Adds `rule` to the [rules][chromaterm.Config.rules] list. Args: rule (chromaterm.Rule): The rule to be added to the list of rules. Raises: TypeError: If `rule` is not an instance of [chromaterm.Rule](../rule/). """ if not isinstance(rule, Rule): raise TypeError('rule must be a chromaterm.Rule') self._rules.append(rule) def remove_rule(self, rule): """Removes rules from the [rules][chromaterm.Config.rules] list. Args: rule (chromaterm.Rule): The rule to be removed from the list of rules. Raises: TypeError: If `rule` is not an instance of [chromaterm.Rule](../rule/). """ if not isinstance(rule, Rule): raise TypeError('rule must be a chromaterm.Rule') return self._rules.remove(rule) @staticmethod def get_insert_index(start, end, inserts): """Returns a tuple containing the start and end indexes for where they should be inserted into the inserts list in order to maintain the position-based descending (reverse) order. Args: start (int): The start position of a match. end (int): The end position of a match. inserts (list): A list of inserts, where the first item of each insert is the position. """ start_index = end_index = None index = -1 # Arrange the inserts in reverse order (index magic over data) for index, (position, _, _, _) in enumerate(inserts): if start_index is None and start >= position: start_index = index # In the case of overlapping matches, other colors exist between # the start and the end, so the end index needs to be located # independently if end_index is None and end > position: end_index = index if start_index is not None and end_index is not None: return start_index, end_index # If an index wasn't found, then it belongs at the end of the list if start_index is None: start_index = index + 1 if end_index is None: end_index = index + 1 return start_index, end_index def get_inserts(self, data, inserts=None): """Returns a list containing the inserts for the color codes relative to data. An insert is a list containing: * A position (index relative to data), * The code to be inserted, * A boolean indicating if its a reset code or not, and * The color type which corresponds to COLOR_TYPES, or None if it's a full SGR reset. The list of inserts is ordered in descending order based on the position of each insert relative to the data. This makes them easy to insert into data without calculating any index offset. Args: data (str): The string for which the matches are gathered. inserts (list): If this list is provided, the inserts are added to it. Any existing inserts are respected during processing, but ensure that they are sorted in descending order based on their position. """ if not isinstance(inserts, list): inserts = [] # A lot of the code here is difficult to comprehend directly, because the # intent might not be immediately visible. You may find it easier to take # a test-driven approach by looking at the test_config_highlight_* tests for start, end, color in self.get_matches(data): start_index, end_index = self.get_insert_index(start, end, inserts) reset_codes = [] # Each color type requires tracking of its respective type for color_type, color_code in color.color_types: start_insert = [start, color_code, False, color_type] end_insert = [ end, self._reset_codes[color_type], True, color_type ] # Find the last color before the end of this match (if any) and # use it as the reset code for this color for insert in inserts[end_index:]: if insert[3] == color_type: end_insert[1] = insert[1] break # No type (a full reset); use the default for this type if insert[2] and insert[3] is None: end_insert[1] = COLOR_TYPES[color_type]['reset'] break # Replace every color reset of the current color type with our # color code to prevent them from interrupting this color for insert in inserts[end_index:start_index]: if insert[2] and insert[3] in (color_type, None): # A full reset is moved forward to our reset (replaced) if insert[3] is None: end_insert[1:4] = insert[1:4] insert[1:4] = color_code, False, color_type # After all of the start inserts are added, the end ones can # placed in reverse order (outward from the match). inserts.insert(start_index, start_insert) reset_codes.insert(0, end_insert) for reset_code in reset_codes: inserts.insert(end_index, reset_code) return inserts def get_matches(self, data): """Returns a list of tuples, each of which containing a start index, an end index, and the [chromaterm.Color](../color/) object for that match. The tuples of the latter rules are towards the end of the list. Args: data (str): A string against which each rule is matched. """ matches = [] for rule in self.rules: matches += rule.get_matches(data) return matches def highlight(self, data, force=None): """Returns a highlighted string of `data`. The matches from the rules are gathered prior to inserting any color codes, making it so the rules can match without the color codes interfering. Args: data (str): A string to highlight. `__str__` of `data` is called. force (bool): If `True`, the color codes are used when highlighting. If `False`, the color codes will be omitted, there by disabling any highlighting and simply returning back `data`. If `None`, the value is determined with `isatty`. """ if force is None: force = getattr(sys.stdout, 'isatty', lambda: False)() if force is False or not self.rules: return str(data) data = str(data) for position, color_code, _, _ in self.get_inserts(data): data = data[:position] + color_code + data[position:] return data def print(self, *args, force=None, **kwargs): """A wrapper for the `print` function. It highlights before printing. Args: *args (...): Arguments to be printed. Highlighted before being passed to the `print` function. force (bool): Passed to [highlight][chromaterm.Config.highlight]. **kwargs (x=y): Keyword arguments passed to the `print` function. """ print(*[self.highlight(arg, force=force) for arg in args], **kwargs)