From bc10bd9a4dbcc2431f9c23f78c399da7de682071 Mon Sep 17 00:00:00 2001 From: Tobias Gruetzmacher Date: Sun, 10 Apr 2016 03:45:00 +0200 Subject: [PATCH] Streamline color output. - Depend on external colorama instead of embedding an old copy. - Move most output code into output module. - Convert pager to context manager. --- dosage | 117 +++++++--------- dosagelib/ansicolor.py | 308 ----------------------------------------- dosagelib/colorama.py | 157 --------------------- dosagelib/fileutil.py | 23 --- dosagelib/output.py | 112 +++++++++++++-- requirements.txt | 5 +- 6 files changed, 155 insertions(+), 567 deletions(-) delete mode 100644 dosagelib/ansicolor.py delete mode 100644 dosagelib/colorama.py delete mode 100644 dosagelib/fileutil.py diff --git a/dosage b/dosage index 60f0f4240..3f162bc6e 100755 --- a/dosage +++ b/dosage @@ -1,7 +1,8 @@ #!/usr/bin/env python -# -*- coding: iso-8859-1 -*- +# -*- coding: utf-8 -*- # Copyright (C) 2004-2005 Tristan Seligmann and Jonathan Jacobs # Copyright (C) 2012-2014 Bastian Kleineidam +# Copyright (C) 2015-2016 Tobias Gruetzmacher # ___ # / \___ ___ __ _ __ _ ___ # / /\ / _ \/ __|/ _` |/ _` |/ _ \ @@ -9,17 +10,14 @@ # /___,' \___/|___/\__,_|\__, |\___| # |___/ -from __future__ import division, print_function +from __future__ import absolute_import, division, print_function import sys import os import argparse -import pydoc -from io import StringIO import dosagelib from dosagelib import events, configuration, singleton, director from dosagelib.output import out from dosagelib.util import internal_error, strlimit -from dosagelib.ansicolor import get_columns class ArgumentParser(argparse.ArgumentParser): @@ -27,18 +25,12 @@ class ArgumentParser(argparse.ArgumentParser): def print_help(self, file=None): """Paginate help message on TTYs.""" - msg = self.format_help() - if file is None: - file = sys.stdout - if hasattr(file, "isatty") and file.isatty(): - pydoc.pager(msg) - else: - print(msg, file=file) - + with out.pager(): + out.info(self.format_help()) Examples = """\ EXAMPLES -List available comics (ca. 3000 at the moment): +List available comics: dosage -l Get the latest comic of for example CalvinAndHobbes and save it in the "Comics" @@ -51,14 +43,14 @@ strips of all of them: """ -def setupOptions(): +def setup_options(): """Construct option parser. @return: new option parser @rtype argparse.ArgumentParser """ kwargs = dict( - description = "A comic downloader and archiver.", - epilog = Examples, + description="A comic downloader and archiver.", + epilog=Examples, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser = ArgumentParser(**kwargs) @@ -92,7 +84,7 @@ def setupOptions(): return parser -def displayVersion(verbose): +def display_version(verbose): """Display application name, version, copyright and license.""" print(configuration.App) print(configuration.Copyright) @@ -126,29 +118,26 @@ def displayVersion(verbose): return 0 -def setOutputInfo(options): +def set_output_info(options): """Set global output level and timestamp option.""" out.level = 0 out.level += options.verbose out.timestamps = options.timestamps - # debug urllib3 - #from requests.packages.urllib3 import add_stderr_logger - #add_stderr_logger() -def displayHelp(options): +def display_help(options): """Print help for comic strips.""" errors = 0 try: for scraperobj in director.getScrapers(options.comic, options.basepath, listing=True): - errors += displayComicHelp(scraperobj) + errors += display_comic_help(scraperobj) except ValueError as msg: out.exception(msg) return 2 return errors -def displayComicHelp(scraperobj): +def display_comic_help(scraperobj): """Print help for a comic.""" orig_context = out.context out.context = scraperobj.getName() @@ -171,19 +160,21 @@ def displayComicHelp(scraperobj): out.context = orig_context -def voteComics(options): +def vote_comics(options): """Vote for comics.""" errors = 0 try: - for scraperobj in director.getScrapers(options.comic, options.basepath, options.adult, options.multimatch): - errors += voteComic(scraperobj) + for scraperobj in director.getScrapers(options.comic, options.basepath, + options.adult, + options.multimatch): + errors += vote_comic(scraperobj) except ValueError as msg: out.exception(msg) errors += 1 return errors -def voteComic(scraperobj): +def vote_comic(scraperobj): """Vote for given comic scraper.""" errors = 0 orig_context = out.context @@ -211,88 +202,82 @@ def voteComic(scraperobj): def run(options): """Execute comic commands.""" - setOutputInfo(options) + set_output_info(options) # ensure only one instance of dosage is running if not options.allow_multiple: - me = singleton.SingleInstance() + singleton.SingleInstance() if options.version: - return displayVersion(options.verbose) + return display_version(options.verbose) if options.list: - return doList() + return do_list() if options.singlelist: - return doList(columnList=False, verbose=options.verbose) + return do_list(column_list=False, verbose=options.verbose) # after this a list of comic strips is needed if not options.comic: out.warn(u'No comics specified, bailing out!') return 1 if options.modulehelp: - return displayHelp(options) + return display_help(options) if options.vote: - return voteComics(options) + return vote_comics(options) return director.getComics(options) -def doList(columnList=True, verbose=False): +def do_list(column_list=True, verbose=False): """List available comics.""" - orig_context = out.context - out.context = u'' - try: - page = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() - if page: - fd = StringIO(u'') - out.setStream(fd) + with out.pager(): out.info(u'Available comic scrapers:') out.info(u'Comics tagged with [%s] require age confirmation with the --adult option.' % TAG_ADULT) out.info(u'Non-english comics are tagged with [%s].' % TAG_LANG) - scrapers = sorted(director.getAllScrapers(listing=True), key=lambda s: s.getName()) - if columnList: - num, disabled = doColumnList(scrapers) + scrapers = sorted(director.getAllScrapers(listing=True), + key=lambda s: s.getName()) + if column_list: + num, disabled = do_column_list(scrapers) else: - num, disabled = doSingleList(scrapers, verbose=verbose) + num, disabled = do_single_list(scrapers, verbose=verbose) out.info(u'%d supported comics.' % num) if disabled: out.info('') out.info(u'Some comics are disabled, they are tagged with [%s:REASON], where REASON is one of:' % TAG_DISABLED) for k in disabled: out.info(u' %-10s %s' % (k, disabled[k])) - if page: - pydoc.pager(fd.getvalue()) - return 0 - finally: - out.context = orig_context + return 0 -def doSingleList(scrapers, verbose=False): +def do_single_list(scrapers, verbose=False): """Get list of scraper names, one per line.""" disabled = {} for num, scraperobj in enumerate(scrapers): if verbose: - displayComicHelp(scraperobj) + display_comic_help(scraperobj) else: - out.info(getTaggedScraperName(scraperobj, reasons=disabled)) + out.info(get_tagged_scraper_name(scraperobj, reasons=disabled)) return num, disabled -def doColumnList(scrapers): +def do_column_list(scrapers): """Get list of scraper names with multiple names per line.""" disabled = {} - screenWidth = get_columns(sys.stdout) + width = out.width # limit name length so at least two columns are there - limit = (screenWidth // 2) - 8 - names = [getTaggedScraperName(scraperobj, limit=limit, reasons=disabled) for scraperobj in scrapers] + limit = (width // 2) - 8 + names = [get_tagged_scraper_name(scraperobj, limit=limit, reasons=disabled) + for scraperobj in scrapers] num = len(names) maxlen = max(len(name) for name in names) - namesPerLine = max(screenWidth // (maxlen + 1), 1) + names_per_line = max(width // (maxlen + 1), 1) while names: - out.info(u''.join(name.ljust(maxlen) for name in names[:namesPerLine])) - del names[:namesPerLine] + out.info(u''.join(name.ljust(maxlen) for name in + names[:names_per_line])) + del names[:names_per_line] return num, disabled TAG_ADULT = "adult" TAG_LANG = "lang" TAG_DISABLED = "dis" -def getTaggedScraperName(scraperobj, limit=None, reasons=None): + +def get_tagged_scraper_name(scraperobj, limit=None, reasons=None): """Get comic scraper name.""" tags = [] if scraperobj.adult: @@ -317,7 +302,7 @@ def getTaggedScraperName(scraperobj, limit=None, reasons=None): def main(): """Parse options and execute commands.""" try: - options = setupOptions().parse_args() + options = setup_options().parse_args() options.basepath = os.path.expanduser(options.basepath) res = run(options) except KeyboardInterrupt: @@ -344,5 +329,3 @@ def viewprof(): if __name__ == '__main__': sys.exit(main()) - #profile() - #viewprof() diff --git a/dosagelib/ansicolor.py b/dosagelib/ansicolor.py deleted file mode 100644 index 8b4427808..000000000 --- a/dosagelib/ansicolor.py +++ /dev/null @@ -1,308 +0,0 @@ -# -*- coding: iso-8859-1 -*- -# Copyright (C) 2000-2014 Bastian Kleineidam -""" -ANSI Color definitions and functions. For Windows systems, the colorama module -uses ctypes and Windows DLLs to generate colored output. - -From Term::ANSIColor, applies also to this module: - -The codes output by this module are standard terminal control codes, -complying with ECMA-48 and ISO 6429 (generally referred to as ``ANSI color'' -for the color codes). The non-color control codes (bold, dark, italic, -underline, and reverse) are part of the earlier ANSI X3.64 standard for -control sequences for video terminals and peripherals. - -Note that not all displays are ISO 6429-compliant, or even X3.64-compliant -(or are even attempting to be so). - -Jean Delvare provided the following table of different common terminal -emulators and their support for the various attributes and others have -helped me flesh it out:: - - clear bold dark under blink reverse conceal - ------------------------------------------------------------------------ - xterm yes yes no yes bold yes yes - linux yes yes yes bold yes yes no - rxvt yes yes no yes bold/black yes no - dtterm yes yes yes yes reverse yes yes - teraterm yes reverse no yes rev/red yes no - aixterm kinda normal no yes no yes yes - PuTTY yes color no yes no yes no - Windows yes no no no no yes no - Cygwin SSH yes yes no color color color yes - -SEE ALSO - -ECMA-048 is available on-line (at least at the time of this writing) at -http://www.ecma-international.org/publications/standards/ECMA-048.HTM. - -ISO 6429 is available from ISO for a charge; the author of this module does -not own a copy of it. Since the source material for ISO 6429 was ECMA-048 -and the latter is available for free, there seems little reason to obtain -the ISO standard. -""" - -import os -import logging -import types -from .fileutil import has_module, is_tty -if os.name == 'nt': - from . import colorama - -has_curses = has_module("curses") - -# Color constants - -# Escape for ANSI colors -AnsiEsc = "\x1b[%sm" - -# Control constants -bold = 'bold' -light = 'light' -underline = 'underline' -blink = 'blink' -invert = 'invert' -concealed = 'concealed' - -# Control numbers -AnsiControl = { - None: '', - bold: '1', - light: '2', - #italic: '3', # unsupported - underline: '4', - blink: '5', - #rapidblink: '6', # unsupported - invert: '7', - concealed: '8', - #strikethrough: '9', # unsupported -} - -# Color constants -default = 'default' -black = 'black' -red = 'red' -green = 'green' -yellow = 'yellow' -blue = 'blue' -purple = 'purple' -cyan = 'cyan' -white = 'white' - -# inverse colors -Black = 'Black' -Red = 'Red' -Green = 'Green' -Yellow = 'Yellow' -Blue = 'Blue' -Purple = 'Purple' -Cyan = 'Cyna' -White = 'White' - -InverseColors = (Black, Red, Green, Yellow, Blue, Purple, Cyan, White) - -# Ansi color numbers; capitalized colors are inverse -AnsiColor = { - None: '0', - default: '0', - black: '30', - red: '31', - green: '32', - yellow: '33', - blue: '34', - purple: '35', - cyan: '36', - white: '37', - Black: '40', - Red: '41', - Green: '42', - Yellow: '43', - Blue: '44', - Purple: '45', - Cyan: '46', - White: '47', -} - -if os.name == 'nt': - # Windows color numbers; capitalized colors are used as background - WinColor = { - None: None, - default: colorama.GREY, - black: colorama.BLACK, - red: colorama.RED, - green: colorama.GREEN, - yellow: colorama.YELLOW, - blue: colorama.BLUE, - purple: colorama.MAGENTA, - cyan: colorama.CYAN, - white: colorama.GREY, - Black: colorama.BLACK, - Red: colorama.RED, - Green: colorama.GREEN, - Yellow: colorama.YELLOW, - Blue: colorama.BLUE, - Purple: colorama.MAGENTA, - Cyan: colorama.CYAN, - White: colorama.GREY, - } - -# pc speaker beep escape code -Beep = "\007" - - -def esc_ansicolor (color): - """convert a named color definition to an escaped ANSI color""" - control = '' - if ";" in color: - control, color = color.split(";", 1) - control = AnsiControl.get(control, '')+";" - cnum = AnsiColor.get(color, '0') - return AnsiEsc % (control+cnum) - -AnsiReset = esc_ansicolor(default) - - -def get_win_color(color): - """Convert a named color definition to Windows console color foreground, - background and style numbers.""" - foreground = background = style = None - control = '' - if ";" in color: - control, color = color.split(";", 1) - if control == bold: - style = colorama.BRIGHT - if color in InverseColors: - background = WinColor[color] - else: - foreground = WinColor.get(color) - return foreground, background, style - - -def has_colors (fp): - """Test if given file is an ANSI color enabled tty.""" - # The is_tty() function ensures that we do not colorize - # redirected streams, as this is almost never what we want - if not is_tty(fp): - return False - if os.name == 'nt': - return True - elif has_curses: - import curses - try: - curses.setupterm(os.environ.get("TERM"), fp.fileno()) - # More than 8 colors are good enough. - return curses.tigetnum("colors") >= 8 - except curses.error: - return False - return False - - -def get_columns (fp): - """Return number of columns for given file.""" - if not is_tty(fp): - return 80 - if os.name == 'nt': - return colorama.get_console_size().X - if has_curses: - import curses - try: - curses.setupterm(os.environ.get("TERM"), fp.fileno()) - return curses.tigetnum("cols") - except curses.error: - pass - return 80 - - -def _write_color_colorama (fp, text, color): - """Colorize text with given color.""" - foreground, background, style = get_win_color(color) - colorama.set_console(foreground=foreground, background=background, - style=style) - fp.write(text) - colorama.reset_console() - - -def _write_color_ansi (fp, text, color): - """Colorize text with given color.""" - fp.write(esc_ansicolor(color)) - fp.write(text) - fp.write(AnsiReset) - - -if os.name == 'nt': - write_color = _write_color_colorama - colorama.init() -else: - write_color = _write_color_ansi - - -class Colorizer (object): - """Prints colored messages to streams.""" - - def __init__ (self, fp): - """Initialize with given stream (file-like object).""" - super(Colorizer, self).__init__() - self.fp = fp - if has_colors(fp): - self.write = self._write_color - else: - self.write = self._write - - def _write (self, text, color=None): - """Print text as-is.""" - self.fp.write(text) - - def _write_color (self, text, color=None): - """Print text with given color. If color is None, print text as-is.""" - if color is None: - self.fp.write(text) - else: - write_color(self.fp, text, color) - - def __getattr__ (self, name): - """Delegate attribute access to the stored stream object.""" - return getattr(self.fp, name) - - -class ColoredStreamHandler (logging.StreamHandler, object): - """Send colored log messages to streams (file-like objects).""" - - def __init__ (self, strm=None): - """Log to given stream (a file-like object) or to stderr if - strm is None. - """ - super(ColoredStreamHandler, self).__init__(strm) - self.stream = Colorizer(self.stream) - # standard log level colors (used by get_color) - self.colors = { - logging.WARN: 'bold;yellow', - logging.ERROR: 'light;red', - logging.CRITICAL: 'bold;red', - logging.DEBUG: 'white', - } - - def get_color (self, record): - """Get appropriate color according to log level. - """ - return self.colors.get(record.levelno, 'default') - - def emit (self, record): - """Emit a record. - - If a formatter is specified, it is used to format the record. - The record is then written to the stream with a trailing newline - [N.B. this may be removed depending on feedback]. - """ - color = self.get_color(record) - msg = self.format(record) - if not hasattr(types, "UnicodeType"): - # no unicode support - self.stream.write("%s" % msg, color=color) - else: - try: - self.stream.write("%s" % msg, color=color) - except UnicodeError: - self.stream.write("%s" % msg.encode("UTF-8"), - color=color) - self.stream.write(os.linesep) - self.flush() diff --git a/dosagelib/colorama.py b/dosagelib/colorama.py deleted file mode 100644 index 48777a361..000000000 --- a/dosagelib/colorama.py +++ /dev/null @@ -1,157 +0,0 @@ -# These functions are part of the python-colorama module -# They have been adjusted slightly for dosage -# -# Copyright: (C) 2010 Jonathan Hartley -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# 3. Neither the name(s) of the copyright holders nor the -# names of its contributors may be used to endorse or promote products -# derived from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -# from winbase.h -STDOUT = -11 -STDERR = -12 - -from ctypes import (windll, byref, Structure, c_char, c_short, c_uint32, - c_ushort, ArgumentError, WinError) - -handles = { - STDOUT: windll.kernel32.GetStdHandle(STDOUT), - STDERR: windll.kernel32.GetStdHandle(STDERR), -} - -SHORT = c_short -WORD = c_ushort -DWORD = c_uint32 -TCHAR = c_char - -class COORD(Structure): - """struct in wincon.h""" - _fields_ = [ - ('X', SHORT), - ('Y', SHORT), - ] - -class SMALL_RECT(Structure): - """struct in wincon.h.""" - _fields_ = [ - ("Left", SHORT), - ("Top", SHORT), - ("Right", SHORT), - ("Bottom", SHORT), - ] - -class CONSOLE_SCREEN_BUFFER_INFO(Structure): - """struct in wincon.h.""" - _fields_ = [ - ("dwSize", COORD), - ("dwCursorPosition", COORD), - ("wAttributes", WORD), - ("srWindow", SMALL_RECT), - ("dwMaximumWindowSize", COORD), - ] - def __str__(self): - """Get string representation of console screen buffer info.""" - return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( - self.dwSize.Y, self.dwSize.X - , self.dwCursorPosition.Y, self.dwCursorPosition.X - , self.wAttributes - , self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right - , self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X - ) - -def GetConsoleScreenBufferInfo(stream_id=STDOUT): - """Get console screen buffer info object.""" - handle = handles[stream_id] - csbi = CONSOLE_SCREEN_BUFFER_INFO() - success = windll.kernel32.GetConsoleScreenBufferInfo( - handle, byref(csbi)) - if not success: - raise WinError() - return csbi - - -def SetConsoleTextAttribute(stream_id, attrs): - """Set a console text attribute.""" - handle = handles[stream_id] - return windll.kernel32.SetConsoleTextAttribute(handle, attrs) - - -# from wincon.h -BLACK = 0 -BLUE = 1 -GREEN = 2 -CYAN = 3 -RED = 4 -MAGENTA = 5 -YELLOW = 6 -GREY = 7 - -# from wincon.h -NORMAL = 0x00 # dim text, dim background -BRIGHT = 0x08 # bright text, dim background - -_default_foreground = None -_default_background = None -_default_style = None - - -def init(): - """Initialize foreground and background attributes.""" - global _default_foreground, _default_background, _default_style - try: - attrs = GetConsoleScreenBufferInfo().wAttributes - except (ArgumentError, WindowsError): - _default_foreground = GREY - _default_background = BLACK - _default_style = NORMAL - else: - _default_foreground = attrs & 7 - _default_background = (attrs >> 4) & 7 - _default_style = attrs & BRIGHT - - -def get_attrs(foreground, background, style): - """Get foreground and background attributes.""" - return foreground + (background << 4) + style - - -def set_console(stream=STDOUT, foreground=None, background=None, style=None): - """Set console foreground and background attributes.""" - if foreground is None: - foreground = _default_foreground - if background is None: - background = _default_background - if style is None: - style = _default_style - attrs = get_attrs(foreground, background, style) - SetConsoleTextAttribute(stream, attrs) - - -def reset_console(stream=STDOUT): - """Reset the console.""" - set_console(stream=stream) - - -def get_console_size(): - """Get the console size.""" - return GetConsoleScreenBufferInfo().dwSize diff --git a/dosagelib/fileutil.py b/dosagelib/fileutil.py deleted file mode 100644 index da98a133f..000000000 --- a/dosagelib/fileutil.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: iso-8859-1 -*- -# Copyright (C) 2012 Bastian Kleineidam -""" -File and path utilities. -""" -import importlib - -def has_module (name): - """Test if given module can be imported. - @return: flag if import is successful - @rtype: bool - """ - try: - importlib.import_module(name) - return True - except (OSError, ImportError): - # some modules (for example HTMLtidy) raise OSError - return False - - -def is_tty (fp): - """Check if a file object is a TTY.""" - return (hasattr(fp, "isatty") and fp.isatty()) diff --git a/dosagelib/output.py b/dosagelib/output.py index 4c278329b..451ff08ca 100644 --- a/dosagelib/output.py +++ b/dosagelib/output.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Copyright (C) 2004-2005 Tristan Seligmann and Jonathan Jacobs # Copyright (C) 2012-2014 Bastian Kleineidam -# Copyright (C) 2005-2016 Tobias Gruetzmacher +# Copyright (C) 2015-2016 Tobias Gruetzmacher + +from __future__ import absolute_import, division, print_function import time import sys @@ -9,7 +11,17 @@ import os import threading import traceback import codecs -from .ansicolor import Colorizer +import contextlib +import pydoc +import io + +try: + import curses +except ImportError: + curses = None + +import colorama +from colorama import Fore, Style, win32 lock = threading.Lock() @@ -23,12 +35,15 @@ def get_threadname(): class Output(object): """Print output with context, indentation and optional timestamps.""" + DEFAULT_WIDTH = 80 + def __init__(self, stream=None): """Initialize context and indentation.""" self.context = None self.level = 0 self.timestamps = False if stream is None: + colorama.init(wrap=False) if hasattr(sys.stdout, "encoding") and sys.stdout.encoding: self.encoding = sys.stdout.encoding else: @@ -38,11 +53,21 @@ class Output(object): else: stream = sys.stdout stream = codecs.getwriter(self.encoding)(stream, 'replace') - self.setStream(stream) + if os.name == 'nt': + stream = colorama.AnsiToWin32(stream).stream + self.stream = stream - def setStream(self, stream): - """Initialize context and indentation.""" - self.stream = Colorizer(stream) + @property + def stream(self): + """The underlaying stream.""" + return self._stream + + @stream.setter + def stream(self, attr): + """Change stream and base stream. base_stream is used for terminal + interaction when _stream is redirected to a pager.""" + self._stream = attr + self._base_stream = attr def info(self, s, level=0): """Write an informational message.""" @@ -50,15 +75,16 @@ class Output(object): def debug(self, s, level=2): """Write a debug message.""" - self.write(s, level=level, color='white') + # "white" is the default color for most terminals... + self.write(s, level=level, color=Fore.WHITE) def warn(self, s): """Write a warning message.""" - self.write(u"WARN: %s" % s, color='bold;yellow') + self.write(u"WARN: %s" % s, color=Style.BRIGHT + Fore.YELLOW) def error(self, s, tb=None): """Write an error message.""" - self.write(u"ERROR: %s" % s, color='light;red') + self.write(u"ERROR: %s" % s, color=Style.DIM + Fore.RED) def exception(self, s): """Write error message with traceback info.""" @@ -81,11 +107,13 @@ class Output(object): self.stream.write(u'%s%s> ' % (timestamp, self.context)) elif self.context is None: self.stream.write(u'%s%s> ' % (timestamp, get_threadname())) - self.stream.write(u'%s' % s, color=color) + if color and self.has_color: + s = u'%s%s%s' % (color, s, Style.RESET_ALL) try: text_type = unicode except NameError: text_type = str + self.stream.write(text_type(s)) self.stream.write(text_type(os.linesep)) self.stream.flush() @@ -95,4 +123,68 @@ class Output(object): for line in line.rstrip(u'\n').split(u'\n'): self.write(line.rstrip(u'\n'), level=level) + @property + def has_color(self): + if not self.is_tty: + return False + elif os.name == 'nt': + return True + elif curses: + try: + curses.setupterm(os.environ.get("TERM"), + self._base_stream.fileno()) + # More than 8 colors are good enough. + return curses.tigetnum("colors") >= 8 + except curses.error: + return False + return False + + @property + def width(self): + """Get width of this output.""" + if not self.is_tty: + return self.DEFAULT_WIDTH + elif os.name == 'nt': + csbi = win32.GetConsoleScreenBufferInfo(win32.STDOUT) + return csbi.dwSize.X + elif curses: + try: + curses.setupterm(os.environ.get("TERM"), + self._base_stream.fileno()) + return curses.tigetnum("cols") + except curses.error: + pass + return self.DEFAULT_WIDTH + + @property + def is_tty(self): + """Is this output stream a terminal?""" + return (hasattr(self._base_stream, "isatty") and + self._base_stream.isatty()) + + @contextlib.contextmanager + def temporary_context(self, context): + """Run a block with a temporary output context""" + orig_context = self.context + self.context = context + try: + yield + finally: + self.context = orig_context + + @contextlib.contextmanager + def pager(self): + """Run the output of a block through a pager.""" + try: + if self.is_tty: + fd = io.StringIO() + self._stream = fd + with self.temporary_context(u''): + yield + if self.is_tty: + pydoc.pager(fd.getvalue()) + finally: + self._stream = self._base_stream + + out = Output() diff --git a/requirements.txt b/requirements.txt index 43854d39a..bb29ed131 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -requests>=2.0 -pbr +colorama lxml +pbr +requests>=2.0