diff options
author | Erik Rose <erik@mozilla.com> | 2011-11-07 01:53:27 -0800 |
---|---|---|
committer | Erik Rose <erik@mozilla.com> | 2011-11-07 01:53:27 -0800 |
commit | 8c7940f196b111742adbcf50caddb62cb78d3497 (patch) | |
tree | 17e25f0c5f61337cb2a51bf2e9e0944102cd0a47 | |
download | blessings-8c7940f196b111742adbcf50caddb62cb78d3497.tar.gz |
Here's Terminator 1.0, lifted out of nose-progressive.terminator-1.0
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | README.rst | 181 | ||||
-rw-r--r-- | setup.py | 32 | ||||
-rw-r--r-- | terminator/__init__.py | 153 | ||||
-rw-r--r-- | terminator/tests.py | 62 |
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')) |