1#!/usr/bin/env python 2 3# Source: http://code.activestate.com/recipes/475116/, with 4# modifications by Daniel Dunbar. 5 6import sys, re, time 7 8def to_bytes(str): 9 # Encode to UTF-8 to get binary data. 10 return str.encode('utf-8') 11 12class TerminalController: 13 """ 14 A class that can be used to portably generate formatted output to 15 a terminal. 16 17 `TerminalController` defines a set of instance variables whose 18 values are initialized to the control sequence necessary to 19 perform a given action. These can be simply included in normal 20 output to the terminal: 21 22 >>> term = TerminalController() 23 >>> print('This is '+term.GREEN+'green'+term.NORMAL) 24 25 Alternatively, the `render()` method can used, which replaces 26 '${action}' with the string required to perform 'action': 27 28 >>> term = TerminalController() 29 >>> print(term.render('This is ${GREEN}green${NORMAL}')) 30 31 If the terminal doesn't support a given action, then the value of 32 the corresponding instance variable will be set to ''. As a 33 result, the above code will still work on terminals that do not 34 support color, except that their output will not be colored. 35 Also, this means that you can test whether the terminal supports a 36 given action by simply testing the truth value of the 37 corresponding instance variable: 38 39 >>> term = TerminalController() 40 >>> if term.CLEAR_SCREEN: 41 ... print('This terminal supports clearning the screen.') 42 43 Finally, if the width and height of the terminal are known, then 44 they will be stored in the `COLS` and `LINES` attributes. 45 """ 46 # Cursor movement: 47 BOL = '' #: Move the cursor to the beginning of the line 48 UP = '' #: Move the cursor up one line 49 DOWN = '' #: Move the cursor down one line 50 LEFT = '' #: Move the cursor left one char 51 RIGHT = '' #: Move the cursor right one char 52 53 # Deletion: 54 CLEAR_SCREEN = '' #: Clear the screen and move to home position 55 CLEAR_EOL = '' #: Clear to the end of the line. 56 CLEAR_BOL = '' #: Clear to the beginning of the line. 57 CLEAR_EOS = '' #: Clear to the end of the screen 58 59 # Output modes: 60 BOLD = '' #: Turn on bold mode 61 BLINK = '' #: Turn on blink mode 62 DIM = '' #: Turn on half-bright mode 63 REVERSE = '' #: Turn on reverse-video mode 64 NORMAL = '' #: Turn off all modes 65 66 # Cursor display: 67 HIDE_CURSOR = '' #: Make the cursor invisible 68 SHOW_CURSOR = '' #: Make the cursor visible 69 70 # Terminal size: 71 COLS = None #: Width of the terminal (None for unknown) 72 LINES = None #: Height of the terminal (None for unknown) 73 74 # Foreground colors: 75 BLACK = BLUE = GREEN = CYAN = RED = MAGENTA = YELLOW = WHITE = '' 76 77 # Background colors: 78 BG_BLACK = BG_BLUE = BG_GREEN = BG_CYAN = '' 79 BG_RED = BG_MAGENTA = BG_YELLOW = BG_WHITE = '' 80 81 _STRING_CAPABILITIES = """ 82 BOL=cr UP=cuu1 DOWN=cud1 LEFT=cub1 RIGHT=cuf1 83 CLEAR_SCREEN=clear CLEAR_EOL=el CLEAR_BOL=el1 CLEAR_EOS=ed BOLD=bold 84 BLINK=blink DIM=dim REVERSE=rev UNDERLINE=smul NORMAL=sgr0 85 HIDE_CURSOR=cinvis SHOW_CURSOR=cnorm""".split() 86 _COLORS = """BLACK BLUE GREEN CYAN RED MAGENTA YELLOW WHITE""".split() 87 _ANSICOLORS = "BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE".split() 88 89 def __init__(self, term_stream=sys.stdout): 90 """ 91 Create a `TerminalController` and initialize its attributes 92 with appropriate values for the current terminal. 93 `term_stream` is the stream that will be used for terminal 94 output; if this stream is not a tty, then the terminal is 95 assumed to be a dumb terminal (i.e., have no capabilities). 96 """ 97 # Curses isn't available on all platforms 98 try: import curses 99 except: return 100 101 # If the stream isn't a tty, then assume it has no capabilities. 102 if not term_stream.isatty(): return 103 104 # Check the terminal type. If we fail, then assume that the 105 # terminal has no capabilities. 106 try: curses.setupterm() 107 except: return 108 109 # Look up numeric capabilities. 110 self.COLS = curses.tigetnum('cols') 111 self.LINES = curses.tigetnum('lines') 112 self.XN = curses.tigetflag('xenl') 113 114 # Look up string capabilities. 115 for capability in self._STRING_CAPABILITIES: 116 (attrib, cap_name) = capability.split('=') 117 setattr(self, attrib, self._tigetstr(cap_name) or '') 118 119 # Colors 120 set_fg = self._tigetstr('setf') 121 if set_fg: 122 for i,color in zip(range(len(self._COLORS)), self._COLORS): 123 setattr(self, color, self._tparm(set_fg, i)) 124 set_fg_ansi = self._tigetstr('setaf') 125 if set_fg_ansi: 126 for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS): 127 setattr(self, color, self._tparm(set_fg_ansi, i)) 128 set_bg = self._tigetstr('setb') 129 if set_bg: 130 for i,color in zip(range(len(self._COLORS)), self._COLORS): 131 setattr(self, 'BG_'+color, self._tparm(set_bg, i)) 132 set_bg_ansi = self._tigetstr('setab') 133 if set_bg_ansi: 134 for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS): 135 setattr(self, 'BG_'+color, self._tparm(set_bg_ansi, i)) 136 137 def _tparm(self, arg, index): 138 import curses 139 return curses.tparm(to_bytes(arg), index).decode('utf-8') or '' 140 141 def _tigetstr(self, cap_name): 142 # String capabilities can include "delays" of the form "$<2>". 143 # For any modern terminal, we should be able to just ignore 144 # these, so strip them out. 145 import curses 146 cap = curses.tigetstr(cap_name) 147 if cap is None: 148 cap = '' 149 else: 150 cap = cap.decode('utf-8') 151 return re.sub(r'\$<\d+>[/*]?', '', cap) 152 153 def render(self, template): 154 """ 155 Replace each $-substitutions in the given template string with 156 the corresponding terminal control string (if it's defined) or 157 '' (if it's not). 158 """ 159 return re.sub(r'\$\$|\${\w+}', self._render_sub, template) 160 161 def _render_sub(self, match): 162 s = match.group() 163 if s == '$$': return s 164 else: return getattr(self, s[2:-1]) 165 166####################################################################### 167# Example use case: progress bar 168####################################################################### 169 170class SimpleProgressBar: 171 """ 172 A simple progress bar which doesn't need any terminal support. 173 174 This prints out a progress bar like: 175 'Header: 0 .. 10.. 20.. ...' 176 """ 177 178 def __init__(self, header): 179 self.header = header 180 self.atIndex = None 181 182 def update(self, percent, message): 183 if self.atIndex is None: 184 sys.stdout.write(self.header) 185 self.atIndex = 0 186 187 next = int(percent*50) 188 if next == self.atIndex: 189 return 190 191 for i in range(self.atIndex, next): 192 idx = i % 5 193 if idx == 0: 194 sys.stdout.write('%-2d' % (i*2)) 195 elif idx == 1: 196 pass # Skip second char 197 elif idx < 4: 198 sys.stdout.write('.') 199 else: 200 sys.stdout.write(' ') 201 sys.stdout.flush() 202 self.atIndex = next 203 204 def clear(self): 205 if self.atIndex is not None: 206 sys.stdout.write('\n') 207 sys.stdout.flush() 208 self.atIndex = None 209 210class ProgressBar: 211 """ 212 A 3-line progress bar, which looks like:: 213 214 Header 215 20% [===========----------------------------------] 216 progress message 217 218 The progress bar is colored, if the terminal supports color 219 output; and adjusts to the width of the terminal. 220 """ 221 BAR = '%s${GREEN}[${BOLD}%s%s${NORMAL}${GREEN}]${NORMAL}%s' 222 HEADER = '${BOLD}${CYAN}%s${NORMAL}\n\n' 223 224 def __init__(self, term, header, useETA=True): 225 self.term = term 226 if not (self.term.CLEAR_EOL and self.term.UP and self.term.BOL): 227 raise ValueError("Terminal isn't capable enough -- you " 228 "should use a simpler progress dispaly.") 229 self.BOL = self.term.BOL # BoL from col#79 230 self.XNL = "\n" # Newline from col#79 231 if self.term.COLS: 232 self.width = self.term.COLS 233 if not self.term.XN: 234 self.BOL = self.term.UP + self.term.BOL 235 self.XNL = "" # Cursor must be fed to the next line 236 else: 237 self.width = 75 238 self.bar = term.render(self.BAR) 239 self.header = self.term.render(self.HEADER % header.center(self.width)) 240 self.cleared = 1 #: true if we haven't drawn the bar yet. 241 self.useETA = useETA 242 if self.useETA: 243 self.startTime = time.time() 244 self.update(0, '') 245 246 def update(self, percent, message): 247 if self.cleared: 248 sys.stdout.write(self.header) 249 self.cleared = 0 250 prefix = '%3d%% ' % (percent*100,) 251 suffix = '' 252 if self.useETA: 253 elapsed = time.time() - self.startTime 254 if percent > .0001 and elapsed > 1: 255 total = elapsed / percent 256 eta = int(total - elapsed) 257 h = eta//3600. 258 m = (eta//60) % 60 259 s = eta % 60 260 suffix = ' ETA: %02d:%02d:%02d'%(h,m,s) 261 barWidth = self.width - len(prefix) - len(suffix) - 2 262 n = int(barWidth*percent) 263 if len(message) < self.width: 264 message = message + ' '*(self.width - len(message)) 265 else: 266 message = '... ' + message[-(self.width-4):] 267 sys.stdout.write( 268 self.BOL + self.term.UP + self.term.CLEAR_EOL + 269 (self.bar % (prefix, '='*n, '-'*(barWidth-n), suffix)) + 270 self.XNL + 271 self.term.CLEAR_EOL + message) 272 if not self.term.XN: 273 sys.stdout.flush() 274 275 def clear(self): 276 if not self.cleared: 277 sys.stdout.write(self.BOL + self.term.CLEAR_EOL + 278 self.term.UP + self.term.CLEAR_EOL + 279 self.term.UP + self.term.CLEAR_EOL) 280 sys.stdout.flush() 281 self.cleared = 1 282 283def test(): 284 tc = TerminalController() 285 p = ProgressBar(tc, 'Tests') 286 for i in range(101): 287 p.update(i/100., str(i)) 288 time.sleep(.3) 289 290if __name__=='__main__': 291 test() 292