|
| 1 | +# coding=utf-8 |
| 2 | +"""Support for ANSI escape sequences which are used for things like applying style to text""" |
| 3 | +import functools |
| 4 | +import re |
| 5 | +from typing import Any, IO |
| 6 | + |
| 7 | +import colorama |
| 8 | +from colorama import Fore, Back, Style |
| 9 | +from wcwidth import wcswidth |
| 10 | + |
| 11 | +# Values for allow_ansi setting |
| 12 | +ANSI_NEVER = 'Never' |
| 13 | +ANSI_TERMINAL = 'Terminal' |
| 14 | +ANSI_ALWAYS = 'Always' |
| 15 | + |
| 16 | +# Controls when ANSI escape sequences are allowed in output |
| 17 | +allow_ansi = ANSI_TERMINAL |
| 18 | + |
| 19 | +# Regular expression to match ANSI escape sequences |
| 20 | +ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m') |
| 21 | + |
| 22 | +# Foreground color presets |
| 23 | +FG_COLORS = { |
| 24 | + 'black': Fore.BLACK, |
| 25 | + 'red': Fore.RED, |
| 26 | + 'green': Fore.GREEN, |
| 27 | + 'yellow': Fore.YELLOW, |
| 28 | + 'blue': Fore.BLUE, |
| 29 | + 'magenta': Fore.MAGENTA, |
| 30 | + 'cyan': Fore.CYAN, |
| 31 | + 'white': Fore.WHITE, |
| 32 | + 'bright_black': Fore.LIGHTBLACK_EX, |
| 33 | + 'bright_red': Fore.LIGHTRED_EX, |
| 34 | + 'bright_green': Fore.LIGHTGREEN_EX, |
| 35 | + 'bright_yellow': Fore.LIGHTYELLOW_EX, |
| 36 | + 'bright_blue': Fore.LIGHTBLUE_EX, |
| 37 | + 'bright_magenta': Fore.LIGHTMAGENTA_EX, |
| 38 | + 'bright_cyan': Fore.LIGHTCYAN_EX, |
| 39 | + 'bright_white': Fore.LIGHTWHITE_EX, |
| 40 | + 'reset': Fore.RESET, |
| 41 | +} |
| 42 | + |
| 43 | +# Background color presets |
| 44 | +BG_COLORS = { |
| 45 | + 'black': Back.BLACK, |
| 46 | + 'red': Back.RED, |
| 47 | + 'green': Back.GREEN, |
| 48 | + 'yellow': Back.YELLOW, |
| 49 | + 'blue': Back.BLUE, |
| 50 | + 'magenta': Back.MAGENTA, |
| 51 | + 'cyan': Back.CYAN, |
| 52 | + 'white': Back.WHITE, |
| 53 | + 'bright_black': Back.LIGHTBLACK_EX, |
| 54 | + 'bright_red': Back.LIGHTRED_EX, |
| 55 | + 'bright_green': Back.LIGHTGREEN_EX, |
| 56 | + 'bright_yellow': Back.LIGHTYELLOW_EX, |
| 57 | + 'bright_blue': Back.LIGHTBLUE_EX, |
| 58 | + 'bright_magenta': Back.LIGHTMAGENTA_EX, |
| 59 | + 'bright_cyan': Back.LIGHTCYAN_EX, |
| 60 | + 'bright_white': Back.LIGHTWHITE_EX, |
| 61 | + 'reset': Back.RESET, |
| 62 | +} |
| 63 | + |
| 64 | +FG_RESET = FG_COLORS['reset'] |
| 65 | +BG_RESET = BG_COLORS['reset'] |
| 66 | +RESET_ALL = Style.RESET_ALL |
| 67 | + |
| 68 | +# ANSI escape sequences not provided by colorama |
| 69 | +UNDERLINE_ENABLE = colorama.ansi.code_to_chars(4) |
| 70 | +UNDERLINE_DISABLE = colorama.ansi.code_to_chars(24) |
| 71 | + |
| 72 | + |
| 73 | +def strip_ansi(text: str) -> str: |
| 74 | + """ |
| 75 | + Strip ANSI escape sequences from a string. |
| 76 | +
|
| 77 | + :param text: string which may contain ANSI escape sequences |
| 78 | + :return: the same string with any ANSI escape sequences removed |
| 79 | + """ |
| 80 | + return ANSI_ESCAPE_RE.sub('', text) |
| 81 | + |
| 82 | + |
| 83 | +def ansi_safe_wcswidth(text: str) -> int: |
| 84 | + """ |
| 85 | + Wrap wcswidth to make it compatible with strings that contains ANSI escape sequences |
| 86 | +
|
| 87 | + :param text: the string being measured |
| 88 | + """ |
| 89 | + # Strip ANSI escape sequences since they cause wcswidth to return -1 |
| 90 | + return wcswidth(strip_ansi(text)) |
| 91 | + |
| 92 | + |
| 93 | +def ansi_aware_write(fileobj: IO, msg: str) -> None: |
| 94 | + """ |
| 95 | + Write a string to a fileobject and strip its ANSI escape sequences if required by allow_ansi setting |
| 96 | +
|
| 97 | + :param fileobj: the file object being written to |
| 98 | + :param msg: the string being written |
| 99 | + """ |
| 100 | + if allow_ansi.lower() == ANSI_NEVER.lower() or \ |
| 101 | + (allow_ansi.lower() == ANSI_TERMINAL.lower() and not fileobj.isatty()): |
| 102 | + msg = strip_ansi(msg) |
| 103 | + fileobj.write(msg) |
| 104 | + |
| 105 | + |
| 106 | +def fg_lookup(fg_name: str) -> str: |
| 107 | + """Look up ANSI escape codes based on foreground color name. |
| 108 | +
|
| 109 | + :param fg_name: foreground color name to look up ANSI escape code(s) for |
| 110 | + :return: ANSI escape code(s) associated with this color |
| 111 | + :raises ValueError if the color cannot be found |
| 112 | + """ |
| 113 | + try: |
| 114 | + ansi_escape = FG_COLORS[fg_name.lower()] |
| 115 | + except KeyError: |
| 116 | + raise ValueError('Foreground color {!r} does not exist.'.format(fg_name)) |
| 117 | + return ansi_escape |
| 118 | + |
| 119 | + |
| 120 | +def bg_lookup(bg_name: str) -> str: |
| 121 | + """Look up ANSI escape codes based on background color name. |
| 122 | +
|
| 123 | + :param bg_name: background color name to look up ANSI escape code(s) for |
| 124 | + :return: ANSI escape code(s) associated with this color |
| 125 | + :raises ValueError if the color cannot be found |
| 126 | + """ |
| 127 | + try: |
| 128 | + ansi_escape = BG_COLORS[bg_name.lower()] |
| 129 | + except KeyError: |
| 130 | + raise ValueError('Background color {!r} does not exist.'.format(bg_name)) |
| 131 | + return ansi_escape |
| 132 | + |
| 133 | + |
| 134 | +def style(text: Any, *, fg: str = '', bg: str = '', bold: bool = False, underline: bool = False) -> str: |
| 135 | + """Styles a string with ANSI colors and/or styles and returns the new string. |
| 136 | +
|
| 137 | + The styling is self contained which means that at the end of the string reset code(s) are issued |
| 138 | + to undo whatever styling was done at the beginning. |
| 139 | +
|
| 140 | + :param text: Any object compatible with str.format() |
| 141 | + :param fg: foreground color. Relies on `fg_lookup()` to retrieve ANSI escape based on name. Defaults to no color. |
| 142 | + :param bg: background color. Relies on `bg_lookup()` to retrieve ANSI escape based on name. Defaults to no color. |
| 143 | + :param bold: apply the bold style if True. Defaults to False. |
| 144 | + :param underline: apply the underline style if True. Defaults to False. |
| 145 | + :return: the stylized string |
| 146 | + """ |
| 147 | + # List of strings that add style |
| 148 | + additions = [] |
| 149 | + |
| 150 | + # List of strings that remove style |
| 151 | + removals = [] |
| 152 | + |
| 153 | + # Convert the text object into a string if it isn't already one |
| 154 | + text = "{}".format(text) |
| 155 | + |
| 156 | + # Process the style settings |
| 157 | + if fg: |
| 158 | + additions.append(fg_lookup(fg)) |
| 159 | + removals.append(FG_RESET) |
| 160 | + |
| 161 | + if bg: |
| 162 | + additions.append(bg_lookup(bg)) |
| 163 | + removals.append(BG_RESET) |
| 164 | + |
| 165 | + if bold: |
| 166 | + additions.append(Style.BRIGHT) |
| 167 | + removals.append(Style.NORMAL) |
| 168 | + |
| 169 | + if underline: |
| 170 | + additions.append(UNDERLINE_ENABLE) |
| 171 | + removals.append(UNDERLINE_DISABLE) |
| 172 | + |
| 173 | + # Combine the ANSI escape sequences with the text |
| 174 | + return "".join(additions) + text + "".join(removals) |
| 175 | + |
| 176 | + |
| 177 | +# Default styles for printing strings of various types. |
| 178 | +# These can be altered to suit an application's needs and only need to be a |
| 179 | +# function with the following structure: func(str) -> str |
| 180 | +style_success = functools.partial(style, fg='green', bold=True) |
| 181 | +style_warning = functools.partial(style, fg='bright_yellow') |
| 182 | +style_error = functools.partial(style, fg='bright_red') |
0 commit comments