diff options
-rw-r--r-- | README.rst | 5 | ||||
-rw-r--r-- | blessings/__init__.py | 114 | ||||
-rw-r--r-- | blessings/tests.py | 137 | ||||
-rw-r--r-- | docs/conf.py | 2 | ||||
-rw-r--r-- | fabfile.py | 2 | ||||
-rw-r--r-- | setup.py | 13 | ||||
-rw-r--r-- | tox.ini | 7 |
7 files changed, 189 insertions, 91 deletions
@@ -325,6 +325,11 @@ Bugs or suggestions? Visit the `issue tracker`_. Version History =============== +1.2 + * Support for Python 3! We need 3.2.3 or greater, because the curses library + couldn't decide whether to accept strs or bytes before that + (http://bugs.python.org/issue10570). + 1.1 * Added nicely named attributes for colors. * Introduced compound formatting. diff --git a/blessings/__init__.py b/blessings/__init__.py index dae686f..33b5cdc 100644 --- a/blessings/__init__.py +++ b/blessings/__init__.py @@ -2,14 +2,26 @@ from collections import defaultdict import curses from curses import tigetstr, setupterm, tparm from fcntl import ioctl +try: + from io import UnsupportedOperation as IOUnsupportedOperation +except ImportError: + class IOUnsupportedOperation(Exception): + """A dummy exception to take the place of Python 3's ``io.UnsupportedOperation`` in Python 2""" + pass +import os from os import isatty, environ +from platform import python_version_tuple import struct import sys from termios import TIOCGWINSZ +if ('3', '0', '0') <= python_version_tuple() < ('3', '2', '2+'): # Good till 3.2.10 + # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. + raise ImportError('Blessings needs Python 3.2.3 or greater for Python 3 support due to http://bugs.python.org/issue10570.') + + __all__ = ['Terminal'] -__version__ = (1, 1) class Terminal(object): @@ -35,14 +47,14 @@ class Terminal(object): def __init__(self, kind=None, stream=None, force_styling=False): """Initialize the terminal. - If ``stream`` is not a tty, I will default to returning '' for all + If ``stream`` is not a tty, I will default to returning ``u''`` for all capability values, so things like piping your output to a file won't strew escape sequences all over the place. The ``ls`` command sets a precedent for this: it defaults to columnar output when being sent to a tty and one-item-per-line when not. :arg kind: A terminal string as taken by ``setupterm()``. Defaults to - the value of the TERM environment variable. + the value of the ``TERM`` environment variable. :arg stream: A file-like object representing the terminal. Defaults to the original value of stdout, like ``curses.initscr()`` does. :arg force_styling: Whether to force the emission of capabilities, even @@ -59,9 +71,13 @@ class Terminal(object): """ if stream is None: stream = sys.__stdout__ - stream_descriptor = (stream.fileno() if hasattr(stream, 'fileno') - and callable(stream.fileno) - else None) + try: + stream_descriptor = (stream.fileno() if hasattr(stream, 'fileno') + and callable(stream.fileno) + else None) + except IOUnsupportedOperation: + stream_descriptor = None + self.is_a_tty = stream_descriptor is not None and isatty(stream_descriptor) if self.is_a_tty or force_styling: # The desciptor to direct terminal initialization sequences to. @@ -80,10 +96,10 @@ class Terminal(object): # Cache capability codes, because IIRC tigetstr requires a # conversation with the terminal. [Now I can't find any evidence of - # that.] + # that.] At any rate, save redoing the work of _resolve_formatter(). self._codes = {} else: - self._codes = NullDict(lambda: NullCallableString('')) + self._codes = NullDict(lambda: NullCallableString()) self.stream = stream @@ -143,6 +159,8 @@ class Terminal(object): ``man terminfo`` for a complete list of capabilities. + Return values are always Unicode. + """ if attr not in self._codes: # Store sugary names under the sugary keys to save a hash lookup. @@ -179,7 +197,7 @@ class Terminal(object): return Location(self, x, y) def _resolve_formatter(self, attr): - """Resolve a sugary or plain capability name, color, or compound formatting function name into a callable string.""" + """Resolve a sugary or plain capability name, color, or compound formatting function name into a callable capability.""" if attr in COLORS: return self._resolve_color(attr) elif attr in COMPOUNDABLES: @@ -190,19 +208,30 @@ class Terminal(object): if all(f in COMPOUNDABLES for f in formatters): # It's a compound formatter, like "bold_green_on_red". Future # optimization: combine all formatting into a single escape - # sequence - return FormattingString(''.join(self._resolve_formatter(s) - for s in formatters), - self) + # sequence. + return FormattingString( + u''.join(self._resolve_formatter(s) for s in formatters), + self) else: return ParametrizingString(self._resolve_capability(attr)) def _resolve_capability(self, atom): - """Return a terminal code for a capname or a sugary name, or ''.""" - return tigetstr(self._sugar.get(atom, atom)) or '' + """Return a terminal code for a capname or a sugary name, or u''. + + The return value is always Unicode, because otherwise it is clumsy + (especially in Python 3) to concatenate with real (Unicode) strings. + + """ + code = tigetstr(self._sugar.get(atom, atom)) + if code: + # We can encode escape sequences as UTF-8 because they never + # contain chars > 127, and UTF-8 never changes anything within that + # range.. + return code.decode('utf-8') + return u'' def _resolve_color(self, color): - """Resolve a color like red or on_bright_green into a callable string.""" + """Resolve a color like red or on_bright_green into a callable capability.""" # TODO: Does curses automatically exchange red and blue and cyan and # yellow when a terminal supports setf/setb rather than setaf/setab? # I'll be blasted if I can find any documentation. The following @@ -218,27 +247,34 @@ class Terminal(object): self) +def derivative_colors(colors): + """Return the names of valid color variants, given the base colors.""" + return set([('on_' + c) for c in colors] + + [('bright_' + c) for c in colors] + + [('on_bright_' + c) for c in colors]) + + COLORS = set(['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']) -COLORS.update(set([('on_' + c) for c in COLORS] + - [('bright_' + c) for c in COLORS] + - [('on_bright_' + c) for c in COLORS])) -del c +COLORS.update(derivative_colors(COLORS)) COMPOUNDABLES = (COLORS | set(['bold', 'underline', 'reverse', 'blink', 'dim', 'italic', 'shadow', 'standout', 'subscript', 'superscript'])) -class ParametrizingString(str): - """A string which can be called to parametrize it as a terminal capability""" +class ParametrizingString(unicode): + """A Unicode string which can be called to parametrize it as a terminal capability""" def __call__(self, *args): try: - return tparm(self, *args) + # Re-encode the cap, because tparm() takes a bytestring in Python + # 3. However, appear to be a plain Unicode string otherwise so + # concats work. + return tparm(self.encode('utf-8'), *args).decode('utf-8') except curses.error: # Catch "must call (at least) setupterm() first" errors, as when # running simply `nosetests` (without progressive) on nose- # progressive. Perhaps the terminal has gone away between calling # tigetstr and calling tparm. - return '' + return u'' except TypeError: # If the first non-int (i.e. incorrect) arg was a string, suggest # something intelligent: @@ -253,11 +289,11 @@ class ParametrizingString(str): raise -class FormattingString(str): - """A string which can be called upon a piece of text to wrap it in formatting""" +class FormattingString(unicode): + """A Unicode string which can be called upon a piece of text to wrap it in formatting""" def __new__(cls, formatting, term): - new = str.__new__(cls, formatting) - new._term = term + new = unicode.__new__(cls, formatting) + new._term = term # TODO: Kill cycle. return new def __call__(self, text): @@ -265,20 +301,28 @@ class FormattingString(str): At the beginning of the string, I prepend the formatting that is my contents. At the end, I append the "normal" sequence to set everything - back to defaults. - - This should work regardless of whether ``text`` is unicode. + back to defaults. The return value is always a Unicode. """ return self + text + self._term.normal -class NullCallableString(str): - """A callable string that returns '' when called with an int and the arg otherwise.""" +class NullCallableString(unicode): + """A dummy class to stand in for ``FormattingString`` and ``ParametrizingString`` + + A callable bytestring that returns an empty Unicode when called with an int + and the arg otherwise. We use this when there is no tty and so all + capabilities are blank. + + """ + def __new__(cls): + new = unicode.__new__(cls, u'') + return new + def __call__(self, arg): if isinstance(arg, int): - return '' - return arg + return u'' + return arg # TODO: Force even strs in Python 2.x to be unicodes? Nah. How would I know what encoding to use to convert it? class NullDict(defaultdict): diff --git a/blessings/tests.py b/blessings/tests.py index d9e7d85..8690f4d 100644 --- a/blessings/tests.py +++ b/blessings/tests.py @@ -1,8 +1,18 @@ # -*- coding: utf-8 -*- +"""Automated tests (as opposed to human-verified test patterns) +It was tempting to mock out curses to get predictable output from ``tigetstr``, +but there are concrete integration-testing benefits in not doing so. For +instance, ``tigetstr`` changed its return type in Python 3.2.3. So instead, we +simply create all our test ``Terminal`` instances with a known terminal type. +All we require from the host machine is that a standard terminfo definition of +xterm-256color exists. + +""" from __future__ import with_statement # Make 2.5-compatible -from StringIO import StringIO from curses import tigetstr, tparm +from functools import partial +from StringIO import StringIO import sys from nose.tools import eq_ @@ -12,6 +22,19 @@ from nose.tools import eq_ from blessings import * +TestTerminal = partial(Terminal, kind='xterm-256color') + + +def unicode_cap(cap): + """Return the result of ``tigetstr`` except as Unicode.""" + return tigetstr(cap).decode('utf-8') + + +def unicode_parm(cap, *parms): + """Return the result of ``tparm(tigetstr())`` except as Unicode.""" + return tparm(tigetstr(cap), *parms).decode('utf-8') + + def test_capability(): """Check that a capability lookup works. @@ -19,32 +42,33 @@ def test_capability(): assumes it will be run from a tty. """ - t = Terminal() - sc = tigetstr('sc') + t = TestTerminal() + sc = unicode_cap('sc') eq_(t.save, sc) eq_(t.save, sc) # Make sure caching doesn't screw it up. def test_capability_without_tty(): """Assert capability templates are '' when stream is not a tty.""" - t = Terminal(stream=StringIO()) - eq_(t.save, '') - eq_(t.red, '') + t = TestTerminal(stream=StringIO()) + eq_(t.save, u'') + eq_(t.red, u'') def test_capability_with_forced_tty(): - t = Terminal(stream=StringIO(), force_styling=True) - assert t.save != '' + """If we force styling, capabilities had better not (generally) be empty.""" + t = TestTerminal(stream=StringIO(), force_styling=True) + eq_(t.save, unicode_cap('sc')) def test_parametrization(): """Test parametrizing a capability.""" - eq_(Terminal().cup(3, 4), tparm(tigetstr('cup'), 3, 4)) + eq_(TestTerminal().cup(3, 4), unicode_parm('cup', 3, 4)) def height_and_width(): """Assert that ``height_and_width()`` returns ints.""" - t = Terminal() + t = TestTerminal() # kind shouldn't matter. assert isinstance(int, t.height) assert isinstance(int, t.width) @@ -56,88 +80,105 @@ def test_stream_attr(): def test_location(): """Make sure ``location()`` does what it claims.""" - t = Terminal(stream=StringIO(), force_styling=True) + t = TestTerminal(stream=StringIO(), force_styling=True) with t.location(3, 4): - t.stream.write('hi') + t.stream.write(u'hi') + + eq_(t.stream.getvalue(), unicode_cap('sc') + + unicode_parm('cup', 4, 3) + + u'hi' + + unicode_cap('rc')) - eq_(t.stream.getvalue(), tigetstr('sc') + - tparm(tigetstr('cup'), 4, 3) + - 'hi' + - tigetstr('rc')) def test_horizontal_location(): """Make sure we can move the cursor horizontally without changing rows.""" - t = Terminal(stream=StringIO(), force_styling=True) + t = TestTerminal(stream=StringIO(), force_styling=True) with t.location(x=5): pass - eq_(t.stream.getvalue(), t.save + tparm(tigetstr('hpa'), 5) + t.restore) + eq_(t.stream.getvalue(), unicode_cap('sc') + + unicode_parm('hpa', 5) + + unicode_cap('rc')) def test_null_fileno(): - """Make sure ``Terinal`` works when ``fileno`` is ``None``. + """Make sure ``Terminal`` works when ``fileno`` is ``None``. This simulates piping output to another program. """ - out = stream=StringIO() + out = StringIO() out.fileno = None - t = Terminal(stream=out) - eq_(t.save, '') + t = TestTerminal(stream=out) + eq_(t.save, u'') def test_mnemonic_colors(): """Make sure color shortcuts work.""" + def color(num): + return unicode_parm('setaf', num) + + def on_color(num): + return unicode_parm('setab', num) + # Avoid testing red, blue, yellow, and cyan, since they might someday - # chance depending on terminal type. - t = Terminal() - eq_(t.white, '\x1b[37m') - eq_(t.green, '\x1b[32m') # Make sure it's different than white. - eq_(t.on_black, '\x1b[40m') - eq_(t.on_green, '\x1b[42m') - eq_(t.bright_black, '\x1b[90m') - eq_(t.bright_green, '\x1b[92m') - eq_(t.on_bright_black, '\x1b[100m') - eq_(t.on_bright_green, '\x1b[102m') + # change depending on terminal type. + t = TestTerminal() + eq_(t.white, color(7)) + eq_(t.green, color(2)) # Make sure it's different than white. + eq_(t.on_black, on_color(0)) + eq_(t.on_green, on_color(2)) + eq_(t.bright_black, color(8)) + eq_(t.bright_green, color(10)) + eq_(t.on_bright_black, on_color(8)) + eq_(t.on_bright_green, on_color(10)) def test_formatting_functions(): """Test crazy-ass formatting wrappers, both simple and compound.""" - t = Terminal() - eq_(t.bold('hi'), t.bold + 'hi' + t.normal) - eq_(t.green('hi'), t.green + 'hi' + t.normal) - eq_(t.bold_green(u'boö'), t.bold + t.green + u'boö' + t.normal) # unicode + t = TestTerminal() + # By now, it should be safe to use sugared attributes. Other tests test those. + eq_(t.bold(u'hi'), t.bold + u'hi' + t.normal) + eq_(t.green('hi'), t.green + u'hi' + t.normal) # Plain strs for Python 2.x + # Test some non-ASCII chars, probably not necessary: + eq_(t.bold_green(u'boö'), t.bold + t.green + u'boö' + t.normal) eq_(t.bold_underline_green_on_red('boo'), - t.bold + t.underline + t.green + t.on_red + 'boo' + t.normal) + t.bold + t.underline + t.green + t.on_red + u'boo' + t.normal) # Don't spell things like this: eq_(t.on_bright_red_bold_bright_green_underline('meh'), - t.on_bright_red + t.bold + t.bright_green + t.underline + 'meh' + t.normal) + t.on_bright_red + t.bold + t.bright_green + t.underline + u'meh' + t.normal) def test_formatting_functions_without_tty(): """Test crazy-ass formatting wrappers when there's no tty.""" - t = Terminal(stream=StringIO()) - eq_(t.bold('hi'), 'hi') - eq_(t.green('hi'), 'hi') - eq_(t.bold_green(u'boö'), u'boö') # unicode - eq_(t.bold_underline_green_on_red('boo'), 'boo') - eq_(t.on_bright_red_bold_bright_green_underline('meh'), 'meh') + t = TestTerminal(stream=StringIO()) + eq_(t.bold(u'hi'), u'hi') + eq_(t.green('hi'), u'hi') + # Test non-ASCII chars, no longer really necessary: + eq_(t.bold_green(u'boö'), u'boö') + eq_(t.bold_underline_green_on_red('loo'), u'loo') + eq_(t.on_bright_red_bold_bright_green_underline('meh'), u'meh') def test_nice_formatting_errors(): """Make sure you get nice hints if you misspell a formatting wrapper.""" - t = Terminal() + t = TestTerminal() try: t.bold_misspelled('hey') except TypeError, e: - assert 'probably misspelled' in e[0] + assert 'probably misspelled' in e.args[0] + + try: + t.bold_misspelled(u'hey') # unicode + except TypeError, e: + assert 'probably misspelled' in e.args[0] try: t.bold_misspelled(None) # an arbitrary non-string except TypeError, e: - assert 'probably misspelled' not in e[0] + assert 'probably misspelled' not in e.args[0] try: t.bold_misspelled('a', 'b') # >1 string arg except TypeError, e: - assert 'probably misspelled' not in e[0] + assert 'probably misspelled' not in e.args[0] diff --git a/docs/conf.py b/docs/conf.py index 594efc6..76d70b7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ copyright = u'2011, Erik Rose' # built documents. # # The short X.Y version. -version = '.'.join(str(i) for i in blessings.__version__) +version = '1.2' # The full version, including alpha/beta/rc tags. release = version @@ -27,10 +27,12 @@ def doc(kind='html'): with cd('docs'): local('make clean %s' % kind) + def test(): # Just calling nosetests results in SUPPORTS_TRANSACTIONS KeyErrors. local('nosetests') + def updoc(): """Build Sphinx docs and upload them to packages.python.org. @@ -2,8 +2,6 @@ import sys from setuptools import setup, find_packages -from blessings import __version__ - extra_setup = {} if sys.version_info >= (3,): @@ -11,7 +9,7 @@ if sys.version_info >= (3,): setup( name='blessings', - version='.'.join(str(i) for i in __version__), + version='1.2', description='A thin, practical wrapper around terminal formatting, positioning, and more', long_description=open('README.rst').read(), author='Erik Rose', @@ -25,11 +23,18 @@ setup( 'Intended Audience :: Developers', 'Natural Language :: English', 'Environment :: Console', + 'Environment :: Console :: Curses', 'Operating System :: POSIX', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.5', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: User Interfaces', 'Topic :: Terminals' ], - keywords=['terminal', 'tty', 'curses', 'formatting', 'color', 'console'], + keywords=['terminal', 'tty', 'curses', 'ncurses', 'formatting', 'color', 'console'], **extra_setup ) @@ -1,6 +1,7 @@ [tox] -envlist = py25, py26, py27 +envlist = py25, py26, py27, py32 [testenv] -commands = nosetests -deps=nose
\ No newline at end of file +commands = nosetests blessings +deps = nose +changedir = .tox # So Python 3 runs don't pick up incompatible, un-2to3'd source from the cwd |