1 2# -*- coding: utf-8 -*- 3# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved 4 5# This Source Code Form is subject to the terms of the Mozilla Public 6# License, v. 2.0. If a copy of the MPL was not distributed with this 7# file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 9""" 10**Enlighten utility submodule** 11 12Provides utility functions and objects 13""" 14 15from collections import OrderedDict 16import inspect 17import os 18import re 19import sys 20import warnings 21 22from blessed.colorspace import RGB_256TABLE 23from blessed.sequences import iter_parse 24 25 26try: 27 BASESTRING = basestring 28except NameError: 29 BASESTRING = str 30 31BASE_DIR = os.path.basename(os.path.dirname(__file__)) 32FORMAT_MAP_SUPPORT = sys.version_info[:2] >= (3, 2) 33RE_COLOR_RGB = re.compile(r'\x1b\[38;2;(\d+);(\d+);(\d+)m') 34RE_ON_COLOR_RGB = re.compile(r'\x1b\[48;2;(\d+);(\d+);(\d+)m') 35RE_COLOR_256 = re.compile(r'\x1b\[38;5;(\d+)m') 36RE_ON_COLOR_256 = re.compile(r'\x1b\[48;5;(\d+)m') 37RE_SET_A = re.compile(r'\x1b\[(\d+)m') 38RE_LINK = re.compile(r'\x1b]8;.*;(.*)\x1b\\') 39 40CGA_COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white') 41HTML_ESCAPE = {'&': '&', '<': '<', '>': '>', '?': '?'} 42 43 44class EnlightenWarning(Warning): 45 """ 46 Generic warning class for Enlighten 47 """ 48 49 50def warn_best_level(message, category): 51 """ 52 Helper function to warn at first frame stack outside of library 53 """ 54 55 level = 5 # Unused default 56 for level, frame in enumerate(inspect.stack(), 1): # pragma: no cover 57 if os.path.basename(os.path.dirname(frame[1])) != BASE_DIR: 58 break 59 60 warnings.warn(message, category=category, stacklevel=level) 61 62 63def format_time(seconds): 64 """ 65 Args: 66 seconds (float): amount of time 67 68 Format time string for eta and elapsed 69 """ 70 71 # Always do minutes and seconds in mm:ss format 72 minutes = seconds // 60 73 hours = minutes // 60 74 rtn = u'%02.0f:%02.0f' % (minutes % 60, seconds % 60) 75 76 # Add hours if there are any 77 if hours: 78 79 rtn = u'%dh %s' % (int(hours % 24), rtn) 80 81 # Add days if there are any 82 days = int(hours // 24) 83 if days: 84 rtn = u'%dd %s' % (days, rtn) 85 86 return rtn 87 88 89def raise_from_none(exc): # pragma: no cover 90 """ 91 Convenience function to raise from None in a Python 2/3 compatible manner 92 """ 93 raise exc 94 95 96if sys.version_info[0] >= 3: # pragma: no branch 97 exec('def raise_from_none(exc):\n raise exc from None') # pylint: disable=exec-used 98 99 100class Justify(object): 101 """ 102 Enumerated type for justification options 103 104 .. py:attribute:: CENTER 105 106 Justify center 107 108 .. py:attribute:: LEFT 109 110 Justify left 111 112 .. py:attribute:: RIGHT 113 114 Justify right 115 116 """ 117 118 CENTER = 'center' 119 LEFT = 'ljust' 120 RIGHT = 'rjust' 121 122 123class Lookahead: 124 """ 125 Args: 126 iterator(:py:term:`iterator`): Instance of an iterator 127 128 Wrapper for an iterator supporting look ahead 129 """ 130 131 def __init__(self, iterator): 132 self.iterator = iterator 133 self.buffer = [] 134 135 def __iter__(self): 136 return self 137 138 def __next__(self): 139 if self.buffer: 140 return self.buffer.pop(0) 141 return next(self.iterator) 142 143 # Python 2 144 next = __next__ 145 146 def lookahead(self, start, stop=None): 147 """ 148 Args: 149 start(int): Positive integer index of first value 150 stop(int): Positive integer index to end before (not returned) 151 152 Retrieve next value(s) in iterator. 153 154 start and stop roughly behave like slice notation, but must be positive 155 """ 156 157 last = max(start, (stop or 0) - 1) 158 159 while last >= len(self.buffer): 160 try: 161 self.buffer.append(next(self.iterator)) 162 except StopIteration: 163 break 164 165 if stop is None: 166 return self.buffer[start] 167 168 return self.buffer[start:stop] 169 170 171class Span(list): 172 """ 173 Container for span classes 174 175 A list is used to preserve order 176 """ 177 def __str__(self): 178 return '<span class="%s">' % ' '.join(self) 179 180 def append_unique(self, item): 181 """ 182 Append only if value is unique 183 """ 184 185 if item not in self: 186 self.append(item) 187 188 189class HTMLConverter(object): 190 """ 191 Args: 192 term(:py:class:`blessed.Terminal`): Blessed terminal instance 193 194 Blessed-based ANSI terminal code to HTML converter 195 """ 196 197 def __init__(self, term): 198 199 self.term = term 200 self.caps = self.term.caps 201 self.normal = [elem[0] for elem in iter_parse(term, term.normal)] 202 self.normal_rem = len(self.normal) - 1 203 self._styles = OrderedDict() 204 self._additional_styles = set() 205 206 @property 207 def style(self): 208 """ 209 Formatted style section for an HTML document 210 211 Styles are cumulative for the life of the instance 212 """ 213 214 out = '<style>\n' 215 216 for style, props in self._styles.items(): 217 out += '.%s {\n%s}\n' % (style, ''.join(' %s: %s;\n' % item for item in props.items())) 218 219 if self._additional_styles: 220 out += '%s\n' % '\n'.join(self._additional_styles) 221 out += '</style>\n' 222 223 return out 224 225 def to_html(self, text): 226 """ 227 Args: 228 text(str): String formatted with ANSI escape codes 229 230 Convert text to HTML 231 232 Formatted text is enclosed in an HTML span and classes are available in HTMLConverter.style 233 234 Supported formatting: 235 - Blink 236 - Bold 237 - Color (8, 16, 256, and RGB) 238 - Italic 239 - Links 240 - Underline 241 """ 242 243 out = '<pre>' 244 open_spans = 0 245 to_out = [] 246 parsed = Lookahead(iter_parse(self.term, text)) 247 normal = self.normal 248 249 # Iterate through parsed text 250 for value, cap in parsed: 251 252 # If there's no capability, it's just regular text 253 if cap is None: 254 255 # Add in any previous spans 256 out += ''.join(str(item) for item in to_out) 257 del to_out[:] # Python 2 compatible .clear() 258 259 # Append character and continue 260 out += HTML_ESCAPE.get(value, value) 261 continue 262 263 # Parse links 264 if cap is self.caps['link']: 265 url = RE_LINK.match(value).group(1).strip() 266 out += '<a href="%s">' % url if url else '<a>' 267 continue 268 269 last_added = to_out[-1] if to_out else None 270 271 # Look for normal to close span 272 if value == normal[0] and \ 273 normal[1:] == [val[0] for val in parsed.lookahead(0, self.normal_rem or None)]: 274 275 # Clear rest of normal 276 for _ in range(self.normal_rem): 277 next(parsed) 278 279 # Ignore empty spans 280 if isinstance(last_added, Span): 281 to_out.pop() 282 open_spans -= 1 283 284 # Only add if there are open spans 285 elif open_spans: 286 to_out.append('</span>') 287 open_spans -= 1 288 289 continue # pragma: no cover # To be fixed in PEP 626 (3.10) 290 291 # Parse styles 292 key, value = self._parse_style(value, cap) 293 294 # If not parsed, ignore 295 if not key: 296 continue 297 298 # Update style sheet 299 self._styles[key] = value 300 301 # Update span classes 302 if isinstance(last_added, Span): 303 last_added.append_unique(key) 304 else: 305 to_out.append(Span([key])) 306 open_spans += 1 307 308 # Process any remaining caps 309 out += ''.join(str(item) for item in to_out) 310 311 # Close any spans that didn't get closed 312 out += '</span>' * open_spans 313 314 out += '</pre>' 315 316 return out 317 318 set_a_codes = { 319 1: ('enlighten-bold', {'font-weight': 'bold'}), 320 3: ('enlighten-italic', {'font-style': 'italic'}), 321 5: ('enlighten-blink', 322 {'animation': 'enlighten-blink-animation 1s steps(5, start) infinite'}), 323 4: ('enlighten-underline', {'text-decoration': 'underline'}), 324 } 325 326 def _parse_style(self, value, cap): # pylint: disable=too-many-return-statements 327 r""" 328 Args: 329 value (str): VT100 terminal code 330 cap(term(:py:class:`~blessed.sequences.Termcap`): Blessed terminal capability 331 332 Parse text attributes of the form '\x1b\[\d+m' into CSS styles 333 """ 334 335 caps = self.caps 336 337 # Parse RGB color foreground 338 if cap is caps['color_rgb']: 339 rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_COLOR_RGB.match(value).groups()) 340 return 'enlighten-fg-%s' % rgb[1:], {'color': rgb} 341 342 # Parse RGB color background 343 if cap is caps['on_color_rgb']: 344 rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_ON_COLOR_RGB.match(value).groups()) 345 return 'enlighten-bg-%s' % rgb[1:], {'background-color': rgb} 346 347 # Weird and inconsistent bug that seems to affect Python <= 3.5 348 # Matches set_a_attributes3 instead of more specific color 256 patterns 349 if cap is caps['set_a_attributes3']: # pragma: no cover 350 if caps['color256'].re_compiled.match(value): 351 cap = caps['color256'] 352 elif caps['on_color256'].re_compiled.match(value): 353 cap = caps['on_color256'] 354 355 # Parse 256 color foreground 356 if cap is caps['color256']: 357 rgb = str(RGB_256TABLE[int(RE_COLOR_256.match(value).group(1))]) 358 return 'enlighten-fg-%s' % rgb[1:], {'color': rgb} 359 360 # Parse 256 color background 361 if cap is caps['on_color256']: 362 rgb = str(RGB_256TABLE[int(RE_ON_COLOR_256.match(value).group(1))]) 363 return 'enlighten-bg-%s' % rgb[1:], {'background-color': rgb} 364 365 # Parse text attributes 366 if cap is caps['set_a_attributes1']: 367 code = int(RE_SET_A.match(value).group(1)) 368 else: 369 return None, None 370 371 # Blink needs additional styling 372 if code == 5: 373 self._additional_styles.add( 374 '@keyframes enlighten-blink-animation {\n to {\n visibility: hidden;\n }\n}' 375 ) 376 377 if code in self.set_a_codes: 378 return self.set_a_codes[code] 379 380 if 30 <= code <= 37: 381 idx = code - 30 382 return 'enlighten-fg-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx])} 383 384 if 40 <= code <= 47: 385 idx = code - 40 386 return 'enlighten-bg-%s' % CGA_COLORS[idx], {'background-color': str(RGB_256TABLE[idx])} 387 388 if 90 <= code <= 97: 389 idx = code - 90 390 return 'enlighten-fg-bright-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx + 8])} 391 392 if 100 <= code <= 107: 393 idx = code - 100 394 return ( 395 'enlighten-bg-bright-%s' % CGA_COLORS[idx], 396 {'background-color': str(RGB_256TABLE[idx + 8])} 397 ) 398 399 return None, None 400