summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorErik Rose <erik@mozilla.com>2011-11-07 01:53:27 -0800
committerErik Rose <erik@mozilla.com>2011-11-07 01:53:27 -0800
commit8c7940f196b111742adbcf50caddb62cb78d3497 (patch)
tree17e25f0c5f61337cb2a51bf2e9e0944102cd0a47
downloadblessings-8c7940f196b111742adbcf50caddb62cb78d3497.tar.gz
Here's Terminator 1.0, lifted out of nose-progressive.terminator-1.0
-rw-r--r--MANIFEST.in1
-rw-r--r--README.rst181
-rw-r--r--setup.py32
-rw-r--r--terminator/__init__.py153
-rw-r--r--terminator/tests.py62
5 files changed, 429 insertions, 0 deletions
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..9561fb1
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+include README.rst
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..adb57df
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,181 @@
+==========
+Terminator
+==========
+
+by Erik Rose
+
+The Pitch
+=========
+
+curses is a great library, but there are a couple situations where it doesn't
+fit:
+
+* You want to use bold, color, and maybe a little positioning without clearing
+ the whole screen first.
+* You want to leave more than one screenful of scrollback in the buffer after
+ your program exits.
+
+In essence, you want to act like a well-behaved command-line app, not a
+full-screen pseudo-GUI one.
+
+If that's your use case--or even if you just want to get the noise out of your
+code--Terminator is for you. Without it, this is how you'd print some
+underlined text at the bottom of the screen::
+
+ from curses import tigetstr, tigetnum, setupterm, tparm
+ from fcntl import ioctl
+ from os import isatty
+ import struct
+ import sys
+ from termios import TIOCGWINSZ
+
+ # If we want to tolerate having our output piped to other commands or
+ # files without crashing, we need to do all this branching:
+ if hasattr(sys.stdout, 'fileno') and isatty(sys.stdout.fileno()):
+ setupterm()
+ sc = tigetstr('sc')
+ cup = tigetstr('cup')
+ rc = tigetstr('rc')
+ underline = tigetstr('smul')
+ plain = tigetstr('sgr0')
+ else:
+ sc = cup = rc = underline = plain = ''
+ print sc # Save cursor position.
+ if cup:
+ # tigetnum('lines') doesn't always update promptly, hence this:
+ height = struct.unpack('hhhh', ioctl(0, TIOCGWINSZ, '\000' * 8))[0]
+ print tparm(cup, height, 0) # Move cursor to bottom.
+ print 'This is {under}underlined{plain}!'.format(under=underline,
+ plain=plain)
+ print rc # Restore cursor position.
+
+Phew! That was long and full of incomprehensible trash! Let's try it again,
+this time with Terminator::
+
+ from terminator import Terminal
+
+ term = Terminal()
+ with term.location(0, term.height):
+ print 'This is {under}underlined{plain}!'.format(under=term.underline,
+ plain=term.no_underline)
+
+It's short, it's obvious, and it keeps all those nasty ``tigetstr()`` and
+``tparm()`` calls out of your code. It also acts intelligently when somebody
+redirects your output to a file, omitting the terminal control codes you don't
+want to see.
+
+What It Provides
+================
+
+Terminator provides just one top-level object: ``Terminal``. Instantiating a
+``Terminal`` figures out whether you're on a terminal at all and, if so, does
+any necessary terminal setup. After that, you can proceed to ask it all sorts
+of things about the terminal.
+
+Simple Formatting
+-----------------
+
+All terminfo capabilities are available as attributes on ``Terminal``
+instances. For example::
+
+ from terminator import Terminal
+
+ term = Terminal()
+ print 'I am ' + term.bold + 'bold' + term.normal + '!'
+
+Other simple capabilities of interest include ``clear_eol`` (clear to the end
+of the line), ``reverse``, ``underline`` and ``no_underline``. You might notice
+that these aren't the raw capability names; we alias a few (soon more) of the
+harder-to-remember ones for readability. If you need to go beyond these, you
+can also reference any string-returning capability listed on the `terminfo
+man page`_ by its value under the "Cap-name" column: for example, ``term.rsubm``.
+
+.. _`terminfo man page`: http://www.manpagez.com/man/5/terminfo/
+
+.. hint:: There's no specific code for undoing most formatting directives.
+ Though the inverse of ``underline`` is ``no_underline``, the only way to turn
+ off ``bold`` or ``reverse`` is ``normal``, which also cancels any custom
+ colors.
+
+ Some other terminal libraries implement fancy state machines to hide this
+ detail, but I elected to keep Terminator easy to integrate and quick to
+ learn.
+
+Parametrized Capabilities
+-------------------------
+
+Some capabilities take parameters. Rather than making you dig up ``tparm()``
+all the time, we simply make such capabilities into callable strings. You can
+pass the parameters right in::
+
+ from terminator import Terminal
+
+ term = Terminal()
+ print 'I am ' + term.color(2) + 'green' + term.normal + '!'
+
+Parametrized capabilities of interest include ``color``, ``bg_color``
+(background color), and ``position`` (though look to ``location()`` first,
+below). If you need more, you can also reference any string-returning
+capability listed on the `terminfo man page`_ by its value under the "Cap-name"
+column.
+
+.. _`terminfo man page`: http://www.manpagez.com/man/5/terminfo/
+
+Temporary Repositioning
+-----------------------
+
+Sometimes you need to flit to a certain location, print something, and then
+return: for example, a progress bar at the bottom of the screen. ``Terminal``
+provides a context manager for doing this concisely::
+
+ from terminator import Terminal
+
+ term = Terminal()
+ with term.location(0, term.height):
+ print 'Here is the bottom.'
+ print 'This is back where I came from.'
+
+Height and Width
+----------------
+
+It's simple to get the height and width of the terminal, in characters::
+
+ from terminator import Terminal
+
+ term = Terminal()
+ height = term.height
+ width = term.width
+
+These are newly updated each time you ask for them, so they're safe to use from
+SIGWINCH handlers.
+
+Pipe Savvy
+----------
+
+If your program isn't attached to a terminal, like if it's being piped to
+another command or redirected to a file, all the capability attributes on
+``Terminal`` will return empty strings. You'll get a nice-looking file without
+any formatting codes gumming up the works.
+
+Future Plans
+============
+
+* Comb through the terminfo man page for useful capabilities with confounding
+ names, and add sugary attribute names for them.
+* A more mnemonic way of specifying colors. Remember that ``setaf`` and
+ ``setf`` take subtly different color mappings, so maybe ``term.red`` would be
+ a good idea.
+* An ``is_terminal`` attr on ``Terminal`` that you can check before drawing
+ progress bars and other such things that are interesting only in a terminal
+ context
+* A relative-positioning version of ``location()``
+
+Version History
+===============
+
+1.0
+ * Extracted Terminator from nose-progressive, my `progress-bar-having,
+ traceback-shortcutting, rootin', tootin' testrunner`_. It provided the
+ tootin' functionality.
+
+.. _`progress-bar-having, traceback-shortcutting, rootin', tootin' testrunner`: http://pypi.python.org/pypi/nose-progressive/
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..c0490fc
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,32 @@
+import sys
+
+from setuptools import setup, find_packages
+
+
+extra_setup = {}
+if sys.version_info >= (3,):
+ extra_setup['use_2to3'] = True
+
+setup(
+ name='terminator',
+ version='1.0',
+ description='A thin, practical wrapper around terminal capabilities',
+ long_description=open('README.rst').read(),
+ author='Erik Rose',
+ author_email='erikrose@grinchcentral.com',
+ license='GPL',
+ packages=find_packages(exclude=['ez_setup']),
+ tests_require=['Nose'],
+ url='http://pypi.python.org/pypi/terminator/',
+ include_package_data=True,
+ classifiers = [
+ 'Intended Audience :: Developers',
+ 'Natural Language :: English',
+ 'Environment :: Console',
+ 'Operating System :: POSIX',
+ 'Topic :: Software Development :: Libraries',
+ 'Topic :: Software Development :: User Interfaces',
+ 'Topic :: Terminals'
+ ],
+ **extra_setup
+)
diff --git a/terminator/__init__.py b/terminator/__init__.py
new file mode 100644
index 0000000..b11b47c
--- /dev/null
+++ b/terminator/__init__.py
@@ -0,0 +1,153 @@
+from collections import defaultdict
+import curses
+from curses import tigetstr, setupterm, tparm
+from fcntl import ioctl
+from os import isatty, environ
+import struct
+import sys
+from termios import TIOCGWINSZ
+
+
+__all__ = ['Terminal']
+
+
+class Terminal(object):
+ """An abstraction around terminal capabilities
+
+ Unlike curses, this doesn't require clearing the screen before doing
+ anything, and it's a little friendlier to use. It keeps the endless calls
+ to tigetstr() and tparm() out of your code, and it acts intelligently when
+ somebody pipes your output to a non-terminal.
+
+ """
+ def __init__(self, kind=None, stream=None):
+ """Initialize the terminal.
+
+ :arg kind: A terminal string as taken by setupterm(). Defaults to 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.
+
+ If ``stream`` is not a tty, I will default to returning '' for all
+ capability values, so things like piping your output to a file will
+ work nicely.
+
+ """
+ if stream is None:
+ stream = sys.__stdout__
+ if hasattr(stream, 'fileno') and isatty(stream.fileno()):
+ # Make things like tigetstr() work:
+ # (Explicit args make setupterm() work even when -s is passed.)
+ setupterm(kind or environ.get('TERM', 'unknown'),
+ stream.fileno())
+ # Cache capability codes, because IIRC tigetstr requires a
+ # conversation with the terminal. [Now I can't find any evidence of
+ # that.]
+ self._codes = {}
+ else:
+ self._codes = NullDict(lambda: '')
+
+ # It's convenient to pass the stream around with the terminal; it's
+ # almost always needed when the terminal is and saves sticking lots of
+ # extra args on things in practice.
+ self.stream = stream
+
+ # Sugary names for commonly-used capabilities, intended to help avoid trips
+ # to the terminfo man page and comments in your code:
+ _sugar = dict(save='sc',
+ restore='rc',
+
+ clear_eol='el',
+ position='cup',
+
+ # TODO: Make this perhaps try setf first then fall back to setaf:
+ color='setaf',
+ # TODO: Perhaps see if setb is true, then fall back to setab.
+ bg_color='setab',
+
+ normal='sgr0',
+ reverse='rev',
+ # 'bold' is just 'bold'.
+ underline='smul',
+ no_underline='rmul')
+
+ def __getattr__(self, attr):
+ """Return parametrized terminal capabilities, like bold.
+
+ For example, you can say ``some_term.bold`` to get the string that
+ turns on bold formatting and ``some_term.sgr0`` to get the string that
+ turns it off again. For a parametrized capability like ``cup``, pass
+ the parameter too: ``some_term.cup(line, column)``.
+
+ ``man terminfo`` for a complete list of capabilities.
+
+ """
+ if attr not in self._codes:
+ # Store sugary names under the sugary keys to save a hash lookup.
+ # Fall back to '' for codes not supported by this terminal.
+ self._codes[attr] = tigetstr(self._sugar.get(attr, attr)) or ''
+ return CallableString(self._codes[attr])
+
+ @property
+ def height(self):
+ return height_and_width()[0]
+
+ @property
+ def width(self):
+ return height_and_width()[1]
+
+ def location(self, x, y):
+ """Return a context manager for temporarily moving the cursor
+
+ Move the cursor to a certain position on entry, let you print stuff
+ there, then return the cursor to its original position::
+
+ term = Terminal()
+ with term.position(2, 5):
+ print 'Hello, world!'
+ for x in xrange(10):
+ print 'I can do it %i times!' % x
+
+ """
+ return Location(x, y, self)
+
+
+class CallableString(str):
+ """A string which can be called to parametrize it as a terminal capability"""
+ def __call__(self, *args):
+ try:
+ return tparm(self, *args)
+ 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 ''
+
+
+class NullDict(defaultdict):
+ """A ``defaultdict`` that pretends to contain all keys"""
+ def __contains__(self, key):
+ return True
+
+
+def height_and_width():
+ """Return a tuple of (terminal height, terminal width)."""
+ # tigetnum('lines') and tigetnum('cols') apparently don't update while
+ # nose-progressive's progress bar is running.
+ return struct.unpack('hhhh', ioctl(0, TIOCGWINSZ, '\000' * 8))[0:2]
+
+
+class Location(object):
+ """Context manager for temporarily moving the cursor"""
+ def __init__(self, x, y, term):
+ self.x, self.y = x, y
+ self.term = term
+
+ def __enter__(self):
+ """Save position and move to progress bar, col 1."""
+ self.term.stream.write(self.term.save) # save position
+ self.term.stream.write(self.term.position(self.y, self.x))
+
+ def __exit__(self, type, value, tb):
+ self.term.stream.write(self.term.restore) # restore position
diff --git a/terminator/tests.py b/terminator/tests.py
new file mode 100644
index 0000000..0efe6e9
--- /dev/null
+++ b/terminator/tests.py
@@ -0,0 +1,62 @@
+from cStringIO import StringIO
+from curses import tigetstr, tparm
+import sys
+
+from nose.tools import eq_
+
+# This tests that __all__ is correct, since we use below everything that should
+# be imported:
+from terminator import *
+
+
+def test_capability():
+ """Check that a capability lookup works.
+
+ Also test that Terminal grabs a reasonable default stream. This test
+ assumes it will be run from a tty.
+
+ """
+ sc = tigetstr('sc')
+ t = Terminal()
+ 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, '')
+
+
+def test_parametrization():
+ """Test parametrizing a capability."""
+ eq_(Terminal().cup(3, 4), tparm(tigetstr('cup'), 3, 4))
+
+
+def height_and_width():
+ """Assert that ``height_and_width()`` returns ints."""
+ t = Terminal()
+ assert isinstance(int, t.height)
+ assert isinstance(int, t.width)
+
+
+def test_stream_attr():
+ """Make sure Terminal exposes a ``stream`` attribute that defaults to something sane."""
+ eq_(Terminal().stream, sys.__stdout__)
+
+
+def test_location():
+ """Make sure ``location()`` does what it claims."""
+ # Let the Terminal grab the actual tty and call setupterm() so things work:
+ t = Terminal()
+
+ # Then rip it away, replacing it with something we can check later:
+ output = t.stream = StringIO()
+
+ with t.location(3, 4):
+ output.write('hi')
+
+ eq_(output.getvalue(), tigetstr('sc') +
+ tparm(tigetstr('cup'), 4, 3) +
+ 'hi' +
+ tigetstr('rc'))