summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorErik Rose <erik@mozilla.com>2011-11-27 13:48:50 -0800
committerErik Rose <erik@mozilla.com>2011-11-27 13:48:50 -0800
commit9baaebbecbcccffb05f836f7ab2258c2b46d7f8f (patch)
tree445946fdca69c4c695ba019ca9261fec246fa75d
parentaf4f0fb075053d1ced78af352c1da07e189025c9 (diff)
parent381bfb79a7476a6e958b7d08d3a80de4c4cabeac (diff)
downloadblessings-9baaebbecbcccffb05f836f7ab2258c2b46d7f8f.tar.gz
Support Python 3 >= 3.2.3.
-rw-r--r--README.rst5
-rw-r--r--blessings/__init__.py114
-rw-r--r--blessings/tests.py137
-rw-r--r--docs/conf.py2
-rw-r--r--fabfile.py2
-rw-r--r--setup.py13
-rw-r--r--tox.ini7
7 files changed, 189 insertions, 91 deletions
diff --git a/README.rst b/README.rst
index c5aabd7..e95e02a 100644
--- a/README.rst
+++ b/README.rst
@@ -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
diff --git a/fabfile.py b/fabfile.py
index cc556bc..ebeeb6f 100644
--- a/fabfile.py
+++ b/fabfile.py
@@ -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.
diff --git a/setup.py b/setup.py
index bc0d3f4..617eb5f 100644
--- a/setup.py
+++ b/setup.py
@@ -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
)
diff --git a/tox.ini b/tox.ini
index 3204750..e1753f2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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