Commits

John Mulligan committed 9663727

initial checkin

Comments (0)

Files changed (5)

+\.py[co]$
+\.swp$
+#!/usr/bin/env python
+"""Setup module: bass 
+"""
+
+class package:
+    name = 'vanity'
+    author = 'John Mulligan'
+    email = 'phlogistonjohn@asynchrono.us'
+    homepage = ''
+    license = 'BSD'
+
+    desc = 'Library containing modules for creating cli apps.'
+
+    dirs = ['vanity']
+
+
+def version():
+    output, input = popen(['hg', 'id'])
+    input.close()
+    id = output.read().split()
+    if not id:
+        return '0-unknown'
+    hex, tags = id.pop(0), []
+    if hex.endswith('+'):
+        hex = hex[:-1]
+    if id:
+        tags = [ tag for tag in id[0].split('/') if (tag != 'tip') ]
+    for tag in tags:
+        if tag.startswith('version-') or tag.startswith('release-'):
+            return tag.split('-', 1)[-1]
+    return '0-%s' % hex
+
+
+def setup():
+    _setup(
+        name = package.name,
+        version = version(),
+        author = package.author,
+        author_email = package.email,
+        url = package.homepage,
+        license = package.license,
+        description = package.desc,
+        long_description = getattr(package, 'details', package.desc),
+
+        packages = package.dirs,
+        )
+
+
+try:
+    from subprocess import Popen, PIPE
+    def popen(cmd):
+        proc = Popen(cmd, stdin=PIPE, stdout=PIPE, close_fds=True)
+        return (proc.stdout, proc.stdin)
+except:
+    import popen2
+    popen = popen2.popen2
+
+try:
+    from setuptools import setup as _setup
+except ImportError:
+    from distutils.core import setup as _setup
+
+
+if __name__ == '__main__':
+    setup()
+
+
+import unittest
+
+from vanity import cli
+
+
+SAMPLE1 = '--output foo.txt bar.txt'.split()
+SAMPLE2 = 'bar.txt -o foo.txt -e --verbose'.split()
+
+TABLE_A = [
+    ('output', 'o', '', 'Output file'),
+    ('extra', 'e', None, 'Add extra markers'),
+    ('verbose', '', None, 'Be verbose'),
+    ]
+
+# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+
+OPTIONS1 = [
+    ('template', 't', '', 'Set template file'),
+    ('output', 'o', '', 'Output file'),
+    ('format', '', '', 'Input/Output format'),
+    ('check', 'c', None, 'Validate input before processing'),
+    ('verbose', '', None, 'Be verbose'),
+    ]
+
+CLI1 = 'a.txt b.txt'
+CLI2 = '-o out.txt a.txt'
+CLI3 = '--verbose -t foo.x -o out.txt b.txt'
+CLI4 = '--bad --verbose c.txt'
+CLI5 = '-c a.txt -o moose.txt'
+
+OPTIONS2 = [
+    ('run-tests', '', None, 'Run tests'),
+    ('skip-checks', '', None, 'Skip detailed checks'),
+    ('set-modules', '', '.', 'Location for modules'),
+    ('set-destination', 'd', '.', 'Destination directory'),
+    ]
+
+CLI6 = ''
+CLI7 = '--run-tests --skip-checks --set-modules /tmp --set-destination=/tmp'
+CLI8 = '-d /home/foo -q'
+CLI9 = '--set-destination --run-tests'
+
+
+def cmd1():
+    pass
+def cmd2():
+    pass
+def cmd3():
+    pass
+
+OPTIONS3 = [
+    ('debug', 'D', None, 'Enable debugging'),
+    ('help',  '?', None, 'Show help'),
+    ]
+COMMANDS1 = [
+    (cmd1, 'draw', OPTIONS2, 'Draw on slate'),
+    (cmd2, 'wipe', [], 'Wipe slate'),
+    (cmd3, 'window', [], 'Open a new window'),
+    ]
+
+CLI10 = 'draw fribble.s'
+CLI11 = '--debug draw fribble.s'
+CLI12 = 'wipe --debug fribble.s'
+CLI13 = 'dr --help -d /home/blarg --skip-checks fribble.s'
+CLI14 = '--help --skip-checks dr -d . fribble.s'
+CLI15 = '--help'
+
+    
+
+
+# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+
+
+class TestCli(unittest.TestCase):
+    def test_cli_1(self):
+        opts, args = cli.parse(OPTIONS1, CLI1.split())
+        self.assertTrue(args == ['a.txt', 'b.txt']) 
+        self.assertTrue(opts['template'] == '')
+        self.assertTrue(opts['output'] == '')
+        self.assertTrue(opts['format'] == '')
+        self.assertFalse(opts['check'])
+        self.assertFalse(opts['verbose'])
+
+    def test_cli_2(self):
+        opts, args = cli.parse(OPTIONS1, CLI2.split())
+        self.assertTrue(args == ['a.txt']) 
+        self.assertTrue(opts['template'] == '')
+        self.assertTrue(opts['output'] == 'out.txt')
+        self.assertTrue(opts['format'] == '')
+        self.assertFalse(opts['check'])
+        self.assertFalse(opts['verbose'])
+
+    def test_cli_3(self):
+        opts, args = cli.parse(OPTIONS1, CLI3.split())
+        self.assertTrue(args == ['b.txt']) 
+        self.assertTrue(opts['template'] == 'foo.x')
+        self.assertTrue(opts['output'] == 'out.txt')
+        self.assertTrue(opts['format'] == '')
+        self.assertFalse(opts['check'])
+        self.assertTrue(opts['verbose'])
+
+    def test_cli_4(self):
+        self.assertRaises(
+            cli.InvalidOption,
+            cli.parse,
+            OPTIONS1, CLI4.split()
+            )
+
+    def test_cli_5(self):
+        opts, args = cli.parse(OPTIONS1, CLI5.split())
+        self.assertTrue(args == ['a.txt']) 
+        self.assertTrue(opts['template'] == '')
+        self.assertTrue(opts['output'] == 'moose.txt')
+        self.assertTrue(opts['format'] == '')
+        self.assertTrue(opts['check'])
+        self.assertFalse(opts['verbose'])
+
+    def test_cli_6(self):
+        opts, args = cli.parse(OPTIONS2, CLI6.split())
+        self.assertTrue(args == [])
+        self.assertFalse(opts.get('run-tests'))
+        self.assertFalse(opts.get('skip-checks'))
+        self.assertTrue(opts.get('set-modules') == '.')
+        self.assertTrue(opts.get('set-destination') == '.')
+
+    def test_cli_7(self):
+        opts, args = cli.parse(OPTIONS2, CLI7.split())
+        self.assertTrue(args == [])
+        self.assertTrue(opts.get('run-tests'))
+        self.assertTrue(opts.get('skip-checks'))
+        self.assertTrue(opts.get('set-modules') == '/tmp')
+        self.assertTrue(opts.get('set-destination') == '/tmp')
+
+    def test_cli_8(self):
+        self.assertRaises(
+            cli.InvalidOption,
+            cli.parse,
+            OPTIONS2, CLI8.split()
+            )
+
+    def test_cli_9(self):
+        opts, args = cli.parse(OPTIONS2, CLI9.split())
+        self.assertTrue(args == [])
+        self.assertFalse(opts.get('run-tests'))
+        self.assertFalse(opts.get('skip-checks'))
+        self.assertTrue(opts.get('set-modules') == '.')
+        # a screwed up command line
+        self.assertTrue(opts.get('set-destination') == '--run-tests')
+
+    def test_cli_10(self):
+        fn, opts, args = cli.parsecommand(OPTIONS3, COMMANDS1, CLI10.split())
+        self.assertTrue(fn == cmd1)
+        self.assertTrue('debug' in opts)
+        self.assertFalse(opts['debug'])
+        self.assertTrue('help' in opts)
+        self.assertFalse(opts['help'])
+        self.assertTrue('run-tests' in opts)
+
+    def test_cli_11(self):
+        fn, opts, args = cli.parsecommand(OPTIONS3, COMMANDS1, CLI11.split())
+        self.assertTrue(fn == cmd1)
+        self.assertTrue('debug' in opts)
+        self.assertTrue(opts['debug'])
+        self.assertTrue('help' in opts)
+        self.assertFalse(opts['help'])
+        self.assertTrue('run-tests' in opts)
+
+    def test_cli_12(self):
+        fn, opts, args = cli.parsecommand(OPTIONS3, COMMANDS1, CLI12.split())
+        self.assertTrue(fn == cmd2)
+        self.assertTrue('debug' in opts)
+        self.assertTrue(opts['debug'])
+        self.assertTrue('help' in opts)
+        self.assertFalse(opts['help'])
+        self.assertFalse('run-tests' in opts)
+
+    def test_cli_13(self):
+        fn, opts, args = cli.parsecommand(OPTIONS3, COMMANDS1, CLI13.split())
+        self.assertTrue(fn == cmd1)
+        self.assertTrue('debug' in opts)
+        self.assertFalse(opts['debug'])
+        self.assertTrue('help' in opts)
+        self.assertTrue(opts['help'])
+        self.assertTrue('run-tests' in opts)
+
+    def test_cli_14(self):
+        self.assertRaises(
+            cli.InvalidOption,
+            cli.parsecommand,
+            OPTIONS3, COMMANDS1, CLI14.split()
+            )
+
+    def test_cli_15(self):
+        fn, opts, args = cli.parsecommand(OPTIONS3, COMMANDS1, CLI15.split())
+        self.assertTrue(fn == None)
+        self.assertTrue(args == [])
+        self.assertTrue('debug' in opts)
+        self.assertTrue('help' in opts)
+        self.assertFalse(opts['debug'])
+        self.assertTrue(opts['help'])
+
+
+    def test_parse_sample_1(self):
+        opts, args = cli.parse(TABLE_A, SAMPLE1)
+        self.assertTrue('output' in opts)
+        self.assertTrue('extra' in opts)
+        self.assertTrue('verbose' in opts)
+        self.assertTrue(opts['output'] == 'foo.txt')
+        self.assertFalse(opts['extra'])
+        self.assertFalse(opts['verbose'])
+        self.assertTrue(args == ['bar.txt'])
+
+    def test_parse_sample_2(self):
+        opts, args = cli.parse(TABLE_A, SAMPLE2)
+        self.assertTrue('output' in opts)
+        self.assertTrue('extra' in opts)
+        self.assertTrue('verbose' in opts)
+        self.assertTrue(opts['output'] == 'foo.txt')
+        self.assertTrue(opts['extra'])
+        self.assertTrue(opts['verbose'])
+        self.assertTrue(args == ['bar.txt'])
+
+    def test_usage(self):
+        ug = list(cli.usage(TABLE_A))
+        #print '\n%s\n' % '\n'.join(ug)
+        self.assertTrue(len(ug) == 3)
+
+
+
+
+if __name__ == '__main__':
+    unittest.main()

vanity/__init__.py

Empty file added.
+#!/usr/bin/env python
+"""easy to use, advanced command line handling
+
+This module uses tables and functions to compile a 
+command line into a useful structure. There is very
+little *magic* to the cli handling, only basic python
+types are returned from the functions.
+
+
+Standard Command Lines
+----------------------
+
+
+To get started, create a options table (a list of tuples)
+in the following form: name, short-option, default-value, and
+description.
+
+>>> options = [
+...     ('signed', 's', None, 'input data is signed'),
+...     ('plaintext', '', None, 'input data is plaintext'),
+...     ('output', 'o', '', 'output filename'),
+...     ('tweak', '', [], 'set tweak keyword'),
+...     ]
+
+The name of the option will also be used for the long style 
+option. If the short option is an empty string, no short
+version will be available for the option. Using None as a
+default value turns that option into a switch, which does
+not take an argument. If the default is a list, then the
+option takes a value and may be specified more than once,
+each time the option appears in the command it's value 
+will be added to the a list in the processed data.
+If the default is any other value, then the takes a single
+value, if the value is not specified on the command line, 
+the default from the table will be used.
+
+Converting a cli into something useful is done by calling
+the parse function.
+
+>>> opts, args = parse(options, sys.argv[1:])
+
+The parse function returns a dictionary and a list, containing
+the processed options and the remaining arguments respectively.
+The values in the opts dict may be True, False, None or a string.
+
+
+Command lines with sub-commands
+-------------------------------
+
+Some command lines take an initial keyword which determines the
+real action that the appliaction will take. Additional options
+and arguments can be specified after the keyword.
+
+>>> commands = [
+...     (func1, 'start', start_opts, 'Start the action'),
+...     (func2, 'stop', [], 'Stop the action'),
+...     (func3, 'refresh', [], 'refresh the database'),
+...     ]
+
+>>> func, opts, args = parsecommand(options, commands, sys.argv[1:])
+
+"""
+
+import getopt as _getopt
+import sys
+
+class CliError(Exception):
+    """Generic CLI Processing Error"""
+    pass
+
+class InvalidOption(CliError):
+    """The CLI was given an invalid option"""
+    pass
+
+class InvalidCommand(CliError):
+    """The CLI was given an invalid command"""
+    pass
+
+class AmbiguousCommand(CliError):
+    """The given command was ambigouous"""
+    def __init__(self, msg, matches=None):
+        CliError.__init__(self, msg)
+        self.matches = matches
+
+
+def arguments(start=1):
+    """Return the arguments given to the application.
+    Convenience function for accessing sys.argv.
+    """
+    return sys.argv[start:]
+
+
+def parse(table, arguments, getopt=None):
+    """Parse a command line, returns an options dict and an args list.
+    """
+    if getopt is None:
+        getopt = _getopt.gnu_getopt
+    table = OptionTable(table)
+    try:
+        raw, args = getopt(arguments, table.shortoptspec(), table.longoptspec())
+    except _getopt.GetoptError, ee:
+        raise InvalidOption(str(ee))
+    return (table.assemble(raw), args)
+
+
+def usage(table, formatter=None):
+    """Generate text that can be used to display cli usage.
+    """
+    if formatter is None:
+        formatter = usage_formatter
+    table = OptionTable(table)
+    maxlen = max(len(long) for long in table.longopts())
+    for opt in table.table():
+        yield formatter(maxlen, *opt)
+
+
+def usage_formatter(maxlen, long, short, default, desc):
+    """Format a single cli option
+    """
+    if short:
+        short = ' (-%s)' % short
+    long = ('--%s' % long) + (' ' * (maxlen - len(long)))
+    return '  %s%5s  %s' % (long, short, desc)
+
+
+def parsecommand(opttable, cmdtable, arguments, getopts=None):
+    """Parse a command line into an command function, an
+    options dict and an arguments list.
+    """
+    if getopts is None:
+        getopts = (_getopt.getopt, _getopt.gnu_getopt)
+    cmds = CommandTable(opttable, cmdtable)
+    short, long = (cmds.opts().shortoptspec(), cmds.opts().longoptspec())
+    try:
+        raw, args = getopts[0](arguments, short, long)
+    except _getopt.GetoptError, ee:
+        raise InvalidOption(str(ee))
+    if not args:
+        return (None, cmds.opts().assemble(raw), [])
+    label, args = args[0], args[1:]
+    (func, id, opts, desc) = cmds.get(label)
+    raw2, args = getopts[1](args, opts.shortoptspec(), opts.longoptspec())
+    return (func, opts.assemble(raw+raw2), args)
+    
+
+
+
+class OptionTable(object):
+    """Collects options as a tool to aid option parsing.
+    """
+    def __init__(self, table):
+        self._table = {}
+        for (long, short, default, desc) in table:
+            self._table[long] = dict(
+                long=long,
+                short=short,
+                default=default,
+                desc=desc,
+                )
+
+    def table(self):
+        """Generate the stored options table.
+        """
+        for opt in self._table.values():
+            yield (opt['long'], opt['short'], opt['default'], opt['desc'])
+
+    def __contains__(self, value):
+        return (value in self._table)
+
+    def longopts(self):
+        """Generates a list of the long options
+        """
+        return self._table.iterkeys()
+
+    def shortopts(self):
+        """Generates a list of the short options
+        """
+        return (og['short'] for og in self._table.values() if og['short'])
+
+    def __iter__(self):
+        return self._table.itervalues()
+
+    def longoptspec(self):
+        """Returns a list containing the long options in a form 
+        suitable for passing to getopt.getopt.
+        """
+        spec = []
+        for opt in self:
+            if opt['default'] is None:
+                spec.append('%s' % opt['long'])
+            else:
+                spec.append('%s=' % opt['long'])
+        return spec
+
+    def shortoptspec(self):
+        """Returns a list containing the short options in a form
+        suitable for passing to getopt.getopt.
+        """
+        spec = []
+        for opt in self:
+            if not opt['short']:
+                continue
+            if opt['default'] is None:
+                spec.append('%s' % opt['short'])
+            else:
+                spec.append('%s:' % opt['short'])
+        return ''.join(spec)
+
+    def getlong(self, key):
+        """Returns the long option belonging to the named
+        table entry.
+        """
+        while key.startswith('-'):
+            key = key[1:]
+        return self._table[key]
+
+    def getshort(self, key):
+        """Returns the short option belonging to the named
+        table entry.
+        """
+        while key.startswith('-'):
+            key = key[1:]
+        for opt in self:
+            if key == opt['short']:
+                return opt
+        raise KeyError('unknown short option: %s' % key)
+
+    def defaults(self):
+        """Return a dict containing the table entry name and
+        it's associated default value.
+        """
+        values = {}
+        for opt in self:
+            name = opt['long']
+            if opt['default'] is None:
+                values[name] = None
+            elif hasattr(opt['default'], 'append'):
+                values[name] = list()
+            else:
+                values[name] = str(opt['default'])
+        return values
+
+    def assemble(self, opts):
+        """Return a dict that maps table entry names to processed
+        options. The opts value should be a list containing key-value
+        tuples like those from the output of getopt.getopt.
+        """
+        assembled = self.defaults()
+        for (opt, value) in opts:
+            if opt.startswith('--'):
+                fetcher = self.getlong
+            else:
+                fetcher = self.getshort
+            try:
+                option = fetcher(opt)
+            except KeyError:
+                raise InvalidOption(opt)
+            name = option['long']
+            if assembled[name] is None:
+                assembled[name] = True
+            elif hasattr(assembled[name], 'append'):
+                assembled[name].append(value)
+            else:
+                assembled[name] = value
+        return assembled
+
+    def __add__(self, other):
+        newtable = OptionTable([])
+        newtable._table.update(self._table)
+        newtable._table.update(other._table)
+        return newtable
+
+
+class CommandTable(object):
+    """Processes a table of commands and the global options.
+    """
+    def __init__(self, globalopts, commands):
+        self._global = OptionTable(globalopts)
+        self._table = {}
+        for command in commands:
+            self._addcommand(command)
+
+    def _addcommand(self, command):
+        func, idents, opts, desc = command
+        for ident in splitidents(idents):
+            self._table[ident] = (
+                func,
+                idents,
+                OptionTable(opts),
+                desc)
+
+    def opts(self):
+        """Return the global options table
+        """
+        return self._global
+
+    def find(self, label):
+        """Given a command name or partial command name,
+        return the closest full command name.
+        If there are no matches a InvalidCommand exception is
+        raised. If there are too many matches an AmbiguousCommand
+        exception is raised.
+        """
+        matches = []
+        for key in self._table.keys():
+            if key.startswith(label):
+                matches.append(key)
+        if len(matches) == 1:
+            return matches[0]
+        if not matches:
+            raise InvalidCommand(label)
+        raise AmbiguousCommand(label, matches)
+
+    def lookup(self, label):
+        """Return the table entry given a full or partial
+        command name.
+        """
+        key = self.find(label)
+        return self._table[key]
+
+    def get(self, label):
+        """For a full or partial command name, return the
+        function, it's identifiers, an options table containing
+        both command and global options and the command
+        description.
+        """
+        (func, id, options, desc) = self.lookup(label)
+        options = self._global + options
+        return (func, id, options, desc)
+
+
+def splitidents(idents):
+    """Return a ident string as a list of idents.
+    """
+    return str(idents).split()
+
+
+# eof
+