root/cly/trunk/cly/console.py

Revision 586, 17.2 kB (checked in by athomas, 1 month ago)

Ensure termwidth()/termheight() return -1 if stdout is not a terminal.

Line 
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (C) 2006-2007 Alec Thomas <alec@swapoff.org>
4 #
5 # This software is licensed as described in the file COPYING, which
6 # you should have received as part of this distribution.
7 #
8
9 """Console/terminal interaction classes and functions.
10
11 This module provides a simple formatting syntax for basic terminal visual
12 control sequences. The syntax is a carat ``^`` followed by a single character.
13
14 Valid colour escape sequences are:
15
16     :``^N``: Reset all formatting.
17     :``^B``: Toggle bold.
18     :``^U``: Toggle underline.
19     :``^0``: Set black foreground.
20     :``^1``: Set red foreground.
21     :``^2``: Set green foreground.
22     :``^3``: Set brown foreground.
23     :``^4``: Set blue foreground.
24     :``^5``: Set magenta foreground.
25     :``^6``: Set cyan foreground.
26     :``^7``: Set white foreground.
27
28 """
29
30 import re
31 import sys
32 import os
33 import codecs
34
35
36 __all__ = """
37 cwrite getch cerror cfatal register_codec cinfo cjustify clen cprint cprintstrip
38 csplice cwarning cwraptext print_table rjustify termheight termwidth wraptoterm
39 """.split()
40
41 __docformat__ = 'restructuredtext en'
42
43
44 _decode_re = re.compile(r'\^([N0-7BU])|[^^]+|\^')
45 _encode_re = re.compile(r'\033(?:[^[]|$)|\033\[(.*?)m')
46 _cprint_strip = re.compile(r'\^([N0-7BU])')
47 _cwrap_re = re.compile(r'(\n)|(\s+)|((?:\^[N0-7BU]|\S)+\b[^\n^\w]*)|(.)')
48 _terminal_type = None
49 _terminal_colours = 0
50
51
52 try:
53     _stdout_is_a_tty = sys.stdout.isatty()
54 except:
55     _stdout_is_a_tty = False
56
57 def mono_cwrite(io, text):
58     io.write(_cprint_strip.sub('', text))
59
60
61 if 'win' in sys.platform:
62     _terminal_type = 'win'
63     import msvcrt
64     def getch():
65         """Get a single character from the terminal."""
66         return msvcrt.getch()
67
68     # Appropriated from
69     #   http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496901
70     STD_INPUT_HANDLE = -10
71     STD_OUTPUT_HANDLE= -11
72     STD_ERROR_HANDLE = -12
73
74     FOREGROUND_BLUE = 0x01 # text color contains blue.
75     FOREGROUND_GREEN= 0x02 # text color contains green.
76     FOREGROUND_RED  = 0x04 # text color contains red.
77     FOREGROUND_WHITE = FOREGROUND_BLUE | FOREGROUND_GREEN | FOREGROUND_RED
78     FOREGROUND_INTENSITY = 0x08 # text color is intensified.
79     BACKGROUND_BLUE = 0x10 # background color contains blue.
80     BACKGROUND_GREEN= 0x20 # background color contains green.
81     BACKGROUND_RED  = 0x40 # background color contains red.
82     BACKGROUND_INTENSITY = 0x80 # background color is intensified.
83     BACKGROUND_WHITE = BACKGROUND_BLUE | BACKGROUND_GREEN | BACKGROUND_RED
84
85     try:
86         import ctypes
87
88         _stdout_handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
89         _stderr_handle = ctypes.windll.kernel32.GetStdHandle(STD_ERROR_HANDLE)
90
91         def _set_windows_colour(color, fd=None):
92             """(color) -> BOOL
93
94             Example: set_color(FOREGROUND_GREEN | FOREGROUND_INTENSITY)
95             """
96             if not fd or fd is sys.stdout:
97                 handle = _stdout_handle
98             elif fd is sys.stderr:
99                 handle = _stderr_handle
100             else:
101                 return False
102             bool = ctypes.windll.kernel32.SetConsoleTextAttribute(handle, color)
103             return bool
104
105
106         def cwrite(io, text):
107             colour_map = {
108                 '0': 0,
109                 '1': FOREGROUND_RED,
110                 '2': FOREGROUND_GREEN,
111                 '3': FOREGROUND_RED | FOREGROUND_GREEN,
112                 '4': FOREGROUND_BLUE,
113                 '5': FOREGROUND_RED | FOREGROUND_BLUE,
114                 '6': FOREGROUND_BLUE | FOREGROUND_GREEN,
115                 '7': FOREGROUND_WHITE,
116                 }
117
118             for match in _decode_re.finditer(text):
119                 code = match.group(1)
120                 if not code:
121                     io.write(match.group(0))
122                 elif code == 'N':
123                     cwrite.state = FOREGROUND_WHITE
124                     _set_windows_colour(cwrite.state, io)
125                 elif code == 'U':
126                     pass
127                 elif code == 'B':
128                     cwrite.state ^= FOREGROUND_INTENSITY
129                     _set_windows_colour(cwrite.state, io)
130                 elif code >= '0' and code <= '7':
131                     cwrite.state &= ~FOREGROUND_WHITE
132                     cwrite.state |= colour_map[code]
133                     _set_windows_colour(cwrite.state, io)
134                 else:
135                     raise NotImplementedError('Unsupported colour code %s' %
136                         match.group(0))
137
138         cwrite.state = FOREGROUND_WHITE
139     except ImportError:
140         def _set_windows_colour(colour, fd=None):
141             return False
142
143 elif _stdout_is_a_tty:
144     _terminal_type = 'ansi'
145     try:
146         import curses
147         import signal
148
149         curses.setupterm()
150         _terminal_colours = curses.tigetnum('colors')
151
152         # Reconfigure curses on window resize
153         def sigwinch_handler(n, frame):
154             curses.setupterm()
155
156         signal.signal(signal.SIGWINCH, sigwinch_handler)
157     except:
158         _terminal_colours = 0
159
160     def getch():
161         """Get a single character from the terminal."""
162         import tty
163         import termios
164
165         fd = sys.stdin.fileno()
166         try:
167             old_settings = termios.tcgetattr(fd)
168         except termios.error:
169             return os.read(fd, 1)
170         try:
171             tty.setraw(fd)
172             ch = os.read(fd, 1)
173         finally:
174             termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
175         return ch
176
177
178     def cwrite(io, text):
179         io.write(_decode(text)[0])
180 else:
181     _terminal_type = 'dumb'
182
183     def getch():
184         return sys.stdin.read(1)
185
186
187     cwrite = mono_cwrite
188
189
190 cwrite.__doc__ = \
191     """Print using simple colour escape codes.
192
193     Colour is not automatically reset at the end of output.
194
195     If ``sys.stdout`` is not a TTY, colour codes will be stripped.
196     """
197
198
199 class _Codec(codecs.Codec):
200     def __init__(self, *args, **kwargs):
201         try:
202             codecs.Codec.__init__(self, *args, **kwargs)
203         except AttributeError:
204             pass
205         self.reset()
206
207     _encode_mapping = {
208         0: '^N', 1: '^B', 4: '^U', 22: '^B', 24: '^U', 30: '^0', 31: '^1',
209         32: '^2', 33: '^3', 34: '^4', 35: '^5', 36: '^6', 37: '^7',
210     }
211
212     def decode(self, input, errors='strict'):
213         return _decode_re.sub(self._decode_match, input)
214
215     def encode(self, input, errors='strict'):
216         return _encode_re.sub(self._encode_match, input)
217
218     def reset(self):
219         self.bold = False
220         self.underline = False
221
222     # Internal methods
223     def _encode_match(self, match):
224         c = match.group(1)
225         if c:
226             return self._encode_mapping[int(c)]
227         return match.group(0)
228
229     def _decode_match(self, match):
230         c = match.group(1)
231         if c:
232             if c == 'B':
233                 self.bold = not self.bold
234                 if self.bold:
235                     return "\033[1m"
236                 else:
237                     return "\033[22m"
238             elif c == 'U':
239                 self.underline = not self.underline
240                 if self.underline:
241                     return "\033[4m"
242                 else:
243                     return "\033[24m"
244             elif c == 'N':
245                 self.underline = self.bold = 0
246                 return "\033[0m"
247             elif c >= '0' and c <= '7':
248                 return "\033[3" + c + "m"
249             else:
250                 return match.group(0)
251         return match.group(0)
252
253
254 class _CodecStreamWriter(_Codec, codecs.StreamWriter):
255     def __init__(self, stream, errors='strict'):
256         _Codec.__init__(self)
257         codecs.StreamWriter.__init__(self, stream, errors)
258         self.errors = errors
259
260     def write(self, object):
261         self.stream.write(self.decode(object))
262
263     def writelines(self, lines):
264         for line in lines:
265             self.write(line)
266             self.write('\n')
267
268
269 class _CodecStreamReader(_Codec, codecs.StreamReader):
270     def __init__(self, stream, errors='strict'):
271         _Codec.__init__(self)
272         codecs.StreamReader.__init__(self, stream, errors)
273
274     def read(self, size=-1, chars=-1):
275         raise NotImplementedError
276
277     def readline(self, size=None, keepends=True):
278         raise NotImplementedError
279
280     def readlines(self, sizehint=None, keepends=True):
281         raise NotImplementedError
282
283     def seek(self, offset, whence=0):
284         self.stream(offset, whence)
285         self.reset()
286
287
288 def _decode(input, errors='strict'):
289     return (_Codec(errors=errors).decode(input), len(input))
290
291
292 def _encode(input, errors='strict'):
293     return (_Codec(errors=errors).encode(input), len(input))
294
295
296 def register_codec():
297     """Register the 'cly' codec with Python.
298
299     The formatting syntax can then be used like any other codec:
300
301     >>> register_codec()
302     >>> '^Bbold^B'.decode('cly')
303     '\\x1b[1mbold\\x1b[22m'
304     >>> '\\x1b[1mbold\\x1b[22m'.encode('cly')
305     '^Bbold^B'
306     """
307     def inner_register(encoding):
308         if encoding != 'cly':
309             return None
310         return (_encode, _decode, _CodecStreamReader, _CodecStreamWriter)
311     return codecs.register(inner_register)
312
313
314 def cprint(*args):
315     """Emulate the ``print`` builtin, with terminal shortcuts."""
316     if args and type(args[0]) is file:
317         stream = args[0]
318         args = args[1:]
319     else:
320         stream = sys.stdout
321     cwrite(stream, ' '.join(map(str, args)) + '\n')
322
323
324 def cprintstrip(*args):
325     """As with cprint, but strip colour codes."""
326     return _cprint_strip.sub('', ' '.join(map(str, args)))
327
328
329 def clen(arg):
330     """Return the length of arg after colour codes are stripped."""
331     return len(cprintstrip(arg))
332
333
334 def cerror(*args):
335     """Print a message in red to stderr."""
336     cprint(sys.stderr, "^1^B" + ' '.join(map(str, args)) + '^N')
337
338
339 def cfatal(*args):
340     """Print a message in red to stderr then exit with status -1."""
341     cprint(sys.stderr, "^1^B" + ' '.join(map(str, args)) + '^N')
342     sys.exit(-1)
343
344
345 def cwarning(*args):
346     """Print a yellow warning message to stderr."""
347     cprint(sys.stderr, "^3^B" + ' '.join(map(str, args)) + '^N')
348
349
350 def cinfo(*args):
351     """Print a green notice."""
352     cprint("^2" + ' '.join(map(str, args)) + '^N')
353
354
355 def termwidth():
356     """Guess the current terminal width.
357
358     Returns -1 if the terminal width can not be determined.
359     """
360     if not _stdout_is_a_tty:
361         return -1
362     try:
363         import curses
364         return curses.tigetnum('cols')
365     except:
366         return int(os.environ.get('COLUMNS', -1))
367
368
369 def termheight():
370     """Guess the current terminal height.
371
372     Returns -1 if the terminal height can not be determined.
373     """
374     if not _stdout_is_a_tty:
375         return -1
376     try:
377         import curses
378         return curses.tigetnum('lines')
379     except:
380         return int(os.environ.get('LINES', -1))
381
382
383 def csplice(text, start=0, end=-1):
384     """Splice a colour encoded string."""
385     out = ''
386     if end == -1:
387         end = len(text)
388     sofar = 0
389     for token in _decode_re.finditer(text):
390         if sofar > end: break
391         txt = token.group(0)
392         if token.group(1):
393             if start < sofar < end:
394                 out += txt
395         else:
396             # Whether beginning and end of segment are in slice
397             bs = start < sofar < end
398             es = start < sofar + len(txt) < end
399             if bs and es:
400                 out += txt
401             elif not bs and es:
402                 out += txt[start - sofar:]
403             elif bs and not es:
404                 out += txt[:end - sofar]
405                 break
406             elif sofar <= start and sofar + len(txt) >= end:
407                 out += txt[start - sofar:end]
408                 break
409             sofar += len(txt)
410     return out
411
412
413 def cwraptext(rtext, width=None, subsequent_indent=''):
414     """Wrap multi-line text to width (defaults to :func:`termwidth`)"""
415     if width is None:
416         width = termwidth()
417         if width == -1:
418             return [rtext]
419     out = []
420     for text in rtext.splitlines():
421         tokens = [t.group(0) for t in _cwrap_re.finditer(text)] + [' ' * width]
422         line = tokens.pop(0)
423         first_line = 1
424
425         def add_line(line, first_line):
426             if clen(line.rstrip()) > width:
427                 tokens.insert(0, csplice(line, width))
428                 line = csplice(line, 0, width)
429             out.append((not first_line and subsequent_indent or '') + line.rstrip())
430             first_line = 0
431             if not out[-1]:
432                 out.pop()
433             return first_line
434
435         if tokens:
436             while tokens:
437                 if clen(line) + clen(tokens[0].rstrip()) > width:
438                     first_line = add_line(line, first_line)
439                     line = tokens.pop(0)
440                 else:
441                     line += tokens.pop(0)
442             if line:
443                 add_line(line, first_line)
444         else:
445             out.append('')
446     return out
447
448
449 def wraptoterm(text, **kwargs):
450     """Wrap the given text to the current terminal width"""
451     return '\n'.join(cwraptext(text, **kwargs))
452
453
454 def rjustify(text, width=None):
455     """Right justify the given text."""
456     if width is None:
457         width = termwidth()
458         if width == -1:
459             return text
460     text = cwraptext(text, width)
461     out = ''
462     for line in text:
463         out += (' ' * (width - clen(line))) + line + '\n'
464     return out.rstrip()
465
466
467 def cjustify(text, width=None):
468     """Centre the given text."""
469     if width is None:
470         width = termwidth()
471         if width == -1:
472             return text
473     text = cwraptext(text, width)
474     out = ''
475     for line in text:
476         out += (' ' * ((width - clen(line)) / 2)) + line + '\n'
477     return out.rstrip()
478
479
480 def print_table(header, table, sep=u' ', indent=u'', expand_to_fit=True,
481                 header_format='^B^U', row_format=('^6', '^B^6'),
482                 min_widths=None, term_width=None):
483     """Print a list of lists as a table, so that columns line up nicely.
484
485     :param header: List of column headings. Will be printed as the first row.
486     :param table: List of lists for the table body.
487     :param sep: The column separator.
488     :param indent: Table indentation as a string.
489     :param header_format: Formatting to use for header.
490     :param row_format: A tuple specifying cycling formatting colours to use for
491                        each row.
492     :param expand_to_fit: If a boolean, signifies whether print_table should
493                           expand the table to the width of the terminal or
494                           compact it as much as possible. If an integer,
495                           specifies the width to expand to.
496     :param min_widths: Columns will be guaranteed to be at least the width of
497                        each element in this list. May also be a dictionary of
498                        column indices to widths.
499     :param term_width: Override terminal width detection.
500
501     :returns: List of strings, one per line.
502
503     Note: In addition to the normal formatting codes supported by :func:`cprint`,
504     :func:`print_table` supports the ``^R`` formatting code, which corresponds
505     to the colour formatting of the current table row.
506     """
507     def ctlen(s):
508         return clen(s.replace('^R', ''))
509
510     seplen = len(sep)
511     # Normalise rows
512     rows = [map(unicode, r) for r in [list(header)] + list(table)]
513     columns = len(rows[0])
514
515     # Scale size_hints percentages to terminal width
516     if term_width is None:
517         term_width = termwidth()
518         if term_width == -1:
519             term_width = max([sum(map(ctlen, r)) + len(r) for r in rows])
520             min_widths = reduce(lambda a, b: map(max, zip(a, b)),
521                                 [map(lambda c: ctlen(c) + 1, r) for r in rows])
522         else:
523             term_width = term_width - (columns - 1) * seplen - ctlen(indent)
524     if not isinstance(min_widths, dict):
525         min_widths = dict(enumerate(min_widths or []))
526
527     # Column widths
528     avg_width = float(term_width) / columns
529     # Use the mid-point between the maximum word width and the maximum length
530     # of the column.
531     widths = [(max([ctlen(w) for cell in column for w in cell.split()])
532                + max(map(ctlen, column))) / 2 + seplen
533               for column in zip(*rows)]
534     #widths = [int(min(c, avg_width)) for c in widths]
535     # Apply user-specified column widths.
536     widths = [max(c, min_widths.get(i, 1)) for i, c in enumerate(widths)]
537     width = sum(widths)
538
539     # Scale columns to fit
540     if width > term_width or expand_to_fit:
541         scale = float(term_width - sum(min_widths.values())) / width
542         widths = [max(int(w * scale), min_widths.get(i, 1))
543                   for i, w in enumerate(widths)]
544
545     row_alt = -1
546     for row in rows:
547         # Cycle through row formats
548         if row_alt == -1:
549             format = header_format
550         else:
551             format = row_format[row_alt % len(row_format)]
552         row_alt += 1
553
554         wrapped = [cwraptext(c.replace('^R', format), widths[i])
555                    for i, c in enumerate(row)]
556         maxrows = max([0] + map(len, wrapped))
557         for col in wrapped:
558             col += [''] * (maxrows - len(col))
559
560         prefix = indent + format
561         for y in range(len(wrapped[0])):
562             cwrite(sys.stdout, prefix)
563             for x, cell in enumerate(wrapped):
564                 cwrite(sys.stdout, cell[y].ljust(widths[x]))
565                 if x < columns - 1:
566                     cwrite(sys.stdout, ' ')
567                 else:
568                     cwrite(sys.stdout, '\n')
569             cwrite(sys.stdout, '^N')
570
571
572 if __name__ == '__main__':
573     import doctest
574     doctest.testmod()
Note: See TracBrowser for help on using the browser.