commander: test and fix fancy help handling
2 files changed, 145 insertions(+), 5 deletions(-)

M test/test_cli_commands.py
M vanity/commander.py
M test/test_cli_commands.py +73 -3
@@ 10,21 10,25 @@ GOPT_A = [
 
 
 def cmd1(fn, **opts):
+    """the number one operation
+
+    This produces the number one. Always.
+    """
     return 1
 
 def cmd2(**opts):
     return 2
 
-def cmd3():
+def cmd3(**opts):
     return 3
 
 
 CMDTABLE_A = [
-    ('foo', cmd1, '', [], 'foo fixes everything'),
+    ('foo', cmd1, '', [], 'FILENAME'),
     ('bar', cmd2, '', [], 'bar breaks it'),
     ('baz', cmd3, 'wibble|hamper', [
         ('shrimp', '', None, 'jumbo shrimp'),
-        ], 'something helpful'),
+        ], ''),
     ]
 
 CMDTABLE_B = [

          
@@ 145,5 149,71 @@ class TestCliCommands(unittest.TestCase)
             commander.launch, GOPT_A, CMDTABLE_A, arguments)
 
 
+    def test_simple_usage(self):
+        arguments = 'foo test.txt'.split()
+        cmd, opts, args = commander.parse(GOPT_A, CMDTABLE_A, arguments)
+        usage = '\n'.join(commander.usage(cmd))
+        self.assert_('FILENAME' in usage)
+        self.assert_('number one' in usage)
+        self.assert_('Always' in usage)
+
+        arguments = 'baz test.txt'.split()
+        cmd, opts, args = commander.parse(GOPT_A, CMDTABLE_A, arguments)
+        usage = '\n'.join(commander.usage(cmd))
+        self.assert_('--shrimp' in usage)
+        self.assert_('jumbo shrimp' in usage)
+
+
+    def test_fancy_help_1(self):
+        arguments = 'foo -h'.split()
+        capture = Capture()
+        try:
+            result = commander.launch(GOPT_A, CMDTABLE_A, arguments)
+        except commander.HelpWanted, herr:
+            commander.handlehelp(herr, GOPT_A, CMDTABLE_A, output=capture)
+        usage = ''.join(capture)
+        self.assert_('number one' in usage)
+
+    def test_fancy_help_2(self):
+        arguments = '-h'.split()
+        capture = Capture()
+        try:
+            result = commander.launch(GOPT_A, CMDTABLE_A, arguments)
+        except commander.HelpWanted, herr:
+            commander.handlehelp(herr, GOPT_A, CMDTABLE_A, output=capture)
+        usage = ''.join(capture)
+        self.assert_('foo' in usage)
+        self.assert_('bar' in usage)
+        self.assert_('baz' in usage)
+
+    def test_fancy_help_3(self):
+        def tst(arguments):
+            capture = Capture()
+            try:
+                result = commander.launch(GOPT_A, CMDTABLE_A, arguments)
+            except commander.HelpWanted, herr:
+                commander.handlehelp(herr, GOPT_A, CMDTABLE_A, output=capture)
+            return ''.join(capture)
+        c1 = tst('foo --help'.split())
+        c2 = tst('foo -h'.split())
+        c3 = tst('--help foo'.split())
+        c4 = tst('help foo'.split())
+        self.assertEqual(c1, c2)
+        self.assertEqual(c2, c3)
+        self.assertEqual(c3, c4)
+
+        c1 = tst('help'.split())
+        c2 = tst('--help'.split())
+        c3 = tst('-h'.split())
+        self.assertEqual(c1, c2)
+        self.assertEqual(c2, c3)
+
+
+            
+class Capture(list):
+    def write(self, x):
+        self.append(x)
+
+
 if __name__ == '__main__':
     unittest.main()

          
M vanity/commander.py +72 -2
@@ 25,6 25,9 @@ class InvalidCommand(cli.CliError):
     """The CLI was given an invalid command"""
     pass
 
+class MissingCommand(cli.CliError):
+    """The user failed to specify a command"""
+    pass
 
 class AmbiguousCommand(cli.CliError):
     """The given command was ambigouous"""

          
@@ 57,7 60,13 @@ def launch(globalopts, cmdtable, argumen
         cmdtable._table[HELP.name] = HELP
         globalopts._table[HELPOPT.name] = HELPOPT
     # parse cli
-    cmd, opts, args = parse(globalopts, cmdtable, arguments)
+    try:
+        cmd, opts, args = parse(globalopts, cmdtable, arguments)
+    except MissingCommand:
+        cmd = HELP
+        opts, args = cli.parse(globalopts, arguments, strict=True)
+        if not opts.get('help'):
+            raise
     # if help requested: raise HelpWanted
     if cmd == HELP or opts.get('help'):
         raise HelpWanted(cmd, opts, args)

          
@@ 77,12 86,64 @@ def parse(globalopts, cmdtable, argument
     cmds = CommandTable(cmdtable)
     opts, args = cli._parse(gopts, arguments, True)
     # TODO : handle missing command
+    if not args:
+        raise MissingCommand()
     cname, args = args[0], args[1:]
     cmd = cmds.find(cname)
     copts, cargs = cli._parse(cmd.opts + gopts, args, cmd.strict)
     copts.update(opts)
     return cmd, copts, cargs
-    
+
+
+def handlehelp(helperr, globalopts, cmdtable, output=None, generic=None):
+    if output is None:
+        output = sys.stdout
+    if not generic:
+        generic = generic_usage
+    # determine what type of help we're getting
+    if helperr.cmd != HELP:
+        check = helperr.cmd.name
+    elif helperr.args:
+        check = helperr.args[0]
+    else:
+        check = None
+    # produce a help text iter
+    cmdtable = CommandTable(cmdtable)
+    try:
+        if check is None:
+            content = generic(cmdtable)
+        else:
+            content = usage(cmdtable.find(check))
+    except cli.CliError, err:
+        content = ['error: %s' % err]
+    for line in content:
+        output.write('%s\n' % line)
+
+
+def generic_usage(cmdtable):
+    yield 'Application Subcommands:'
+    yield ''
+    for cmd in sorted(cmdtable):
+        for line in usage(cmd, short=True):
+            yield '  %-12s  %s' % (cmd.name, line)
+
+
+
+def usage(cmd, short=False, prefix=''):
+    if short:
+        yield cmd.docstring().splitlines()[0]
+        return
+    yield 'usage: %s%s [OPTIONS] %s' % (prefix, cmd.name, cmd.help)
+    doc = cmd.docstring().strip()
+    if doc:
+        yield ''
+        yield '%s' % doc
+        yield ''
+    if cmd.opts and list(cmd.opts.longopts()):
+        yield 'OPTIONS:'
+        for line in cli.usage(cmd.opts):
+            yield line
+
 
 class Command(object):
     """A thin class representing a command.

          
@@ 108,6 169,15 @@ class Command(object):
         else:
             return []
 
+    def docstring(self):
+        if self.target and hasattr(self.target, '__doc__'):
+            return self.target.__doc__ or 'No usage available'
+        else:
+            return 'No usage available'
+    
+    def __cmp__(self, other):
+        return cmp(self.name, other.name)
+
     @classmethod
     def convert(cls, obj):
         if isinstance(obj, cls):