Repository: clld/clldutils

Issue:
`clilib` allow custom names for sub-commands passed to ArgumentParser
Right now, subcommand names for `clilib.ArgumentParser` can only be function names. Thus, reserved words like `class` are impossible and names like `list` would cause shadowing of built-ins where the function is defined. So there should be a way to register callables as commands under custom names.

Discussion:

Patch:
diff --git a/clldutils/clilib.py b/clldutils/clilib.py
index f2851d4..3df3c0d 100644
--- a/clldutils/clilib.py
+++ b/clldutils/clilib.py
@@ -10,17 +10,53 @@ class ParserError(Exception):
     pass
 
 
+# Global registry for commands.
+# Note: This registry is global so it can only be used for one ArgumentParser instance.
+# Otherwise, different ArgumentParsers will share the same sub-commands which will rarely
+# be intended.
+_COMMANDS = []
+
+
+class Command(object):
+    def __init__(self, func, name=None):
+        self.func = func
+        self.name = name
+
+    @property
+    def doc(self):
+        return self.func.__doc__
+
+    def __call__(self, args):
+        return self.func(args)
+
+
+def command(name=None):
+    def wrap(f):
+        _COMMANDS.append(Command(f, name=name))
+        return f
+    return wrap
+
+
+def _attr(obj, attr):
+    return getattr(obj, attr, getattr(obj, '__{0}__'.format(attr), None))
+
+
 class ArgumentParser(argparse.ArgumentParser):
     """
-    An command line argument parser supporting sub-commands in a simple way.
+    A command line argument parser supporting sub-commands in a simple way.
+
+    Sub-commands can be registered in one of two ways:
+    - Passing functions as positional arguments into `ArgumentParser.__init__`.
+    - Decorating functions with the `command` decorator.
     """
     def __init__(self, pkg_name, *commands, **kw):
+        commands = commands or _COMMANDS
         kw.setdefault(
             'description', "Main command line interface of the %s package." % pkg_name)
         kw.setdefault(
             'epilog', "Use '%(prog)s help <cmd>' to get help about individual commands.")
         argparse.ArgumentParser.__init__(self, **kw)
-        self.commands = OrderedDict([(cmd.__name__, cmd) for cmd in commands])
+        self.commands = OrderedDict([(_attr(cmd, 'name'), cmd) for cmd in commands])
         self.pkg_name = pkg_name
         self.add_argument("--verbosity", help="increase output verbosity")
         self.add_argument('command', help='|'.join(self.commands.keys()))
@@ -31,7 +67,7 @@ class ArgumentParser(argparse.ArgumentParser):
         if args.command == 'help' and len(args.args):
             # As help text for individual commands we simply re-use the docstrings of the
             # callables registered for the command:
-            print(self.commands[args.args[0]].__doc__)
+            print(_attr(self.commands[args.args[0]], 'doc'))
         else:
             if args.command not in self.commands:
                 print('invalid command')
@@ -41,7 +77,7 @@ class ArgumentParser(argparse.ArgumentParser):
                 self.commands[args.command](args)
             except ParserError as e:
                 print(e)
-                print(self.commands[args.command].__doc__)
+                print(_attr(self.commands[args.command], 'doc'))
                 return 64
             except Exception as e:
                 if catch_all:


Test patch:
diff --git a/clldutils/tests/test_clilib.py b/clldutils/tests/test_clilib.py
index cf96215..f82f329 100644
--- a/clldutils/tests/test_clilib.py
+++ b/clldutils/tests/test_clilib.py
@@ -9,7 +9,7 @@ from clldutils.testing import capture, capture_all
 
 class Tests(TestCase):
     def test_ArgumentParser(self):
-        from clldutils.clilib import ArgumentParser, ParserError
+        from clldutils.clilib import ArgumentParser, ParserError, command
 
         def cmd(args):
             """
@@ -36,6 +36,19 @@ class Tests(TestCase):
             self.assertNotEqual(res[0], 0)
             self.assertTrue(res[1].startswith('invalid'))
 
+        @command(name='list')
+        def f(args):
+            """
+            my name is list
+            """
+            return
+
+        parser = ArgumentParser('pkg')
+        with capture(parser.main, args=['help', 'list']) as out:
+            self.assertIn('my name is list', out)
+
+        self.assertEqual(parser.main(args=['list', 'arg']), 0)
+
     def test_cmd_error(self):
         from clldutils.clilib import ArgumentParser