Commits

Andy Mikhailenko committed b08aad4

Added bash completion for commands. Options are not yet supported. User needs to bind completion to certain script name (to be documented). Tests are provided (for internal API only).

Comments (0)

Files changed (7)

-version = '0.9.0'
+version = '0.10.0'

argh/completion.py

+# -*- coding: utf-8 -*-
+"""
+Shell completion
+================
+
+... warning::
+
+    TODO: describe how to install
+
+"""
+import sys
+import os
+
+from argh.utils import get_subparsers
+
+
+__all__ = ['autocomplete']
+
+
+def autocomplete(root_parser):
+    if not os.environ.get('ARGH_AUTO_COMPLETE'):
+        return
+
+    cwords = os.environ['COMP_WORDS'].split()[1:]
+    cword = int(os.environ['COMP_CWORD'])  # XXX do we need this at all?
+
+    choices = _autocomplete(root_parser, cwords, cword)
+
+    print ' '.join(choices)
+
+    sys.exit(1)
+
+def _autocomplete(root_parser, cwords, cword):
+
+    def _collect_choices(parser, word):
+        for a in parser._actions:
+            if a.choices:
+                for choice in a.choices:
+                    if word:
+                        if choice.startswith(word):
+                            yield choice
+                    else:
+                        yield choice
+
+    choices = []
+
+    # dig into the tree of parsers until we can yield no more choices
+
+    # 1 ['']                      root parser  -> 'help fixtures'
+    # 2 ['', 'fi']                root parser  -> 'fixtures'
+    # 2 ['', 'fixtures']          subparser    -> 'load dump'
+    # 3 ['', 'fixtures', 'lo']    subparser    -> 'load'
+    # 3 ['', 'fixtures', 'load']  subparser    -> ''
+
+    parser = root_parser
+    choices = _collect_choices(parser, '')
+    for word in cwords:
+        # find the subparser and switch to it
+        subparsers = get_subparsers(parser)
+        if not subparsers:
+            break
+        if word in subparsers.choices:
+            parser = subparsers.choices[word]
+            word = ''
+        choices = _collect_choices(parser, word)
+
+    return choices

argh/decorators.py

+# -*- coding: utf-8 -*-
 """
 Command decorators
 ==================
         return func
     return wrapper
 
-def generator(func):
+def generator(func):  # pragma: no cover
     """
     .. warning::
 
 from types import GeneratorType
 
 from argh.exceptions import CommandError
+from argh.utils import get_subparsers
+from argh.completion import autocomplete
 
 
-__all__ = ['ArghParser', 'add_commands', 'dispatch', 'confirm']
-
-
-def _get_subparsers(parser):
-    """Returns the :class:`argparse._SupParsersAction` instance for given
-    :class:`ArgumentParser` instance as would have been returned by
-    :meth:`ArgumentParser.add_subparsers`. The problem with the latter is that
-    it only works once and raises an exception on the second attempt, and the
-    public API seems to lack a method to get *existing* subparsers.
-    """
-    # note that ArgumentParser._subparsers is *not* what is returned by
-    # ArgumentParser.add_subparsers().
-    if parser._subparsers:
-        actions = [a for a in parser._actions
-                   if isinstance(a, argparse._SubParsersAction)]
-        assert len(actions) == 1
-        return actions[0]
-    else:
-        return parser.add_subparsers()
-
+__all__ = ['ArghParser', 'add_commands', 'autocomplete', 'dispatch', 'confirm']
 def add_commands(parser, functions, namespace=None, title=None,
                  description=None, help=None):
     """Adds given functions as commands to given parser.
         we'll simply add a workaround a keep it compatibile.
 
     """
-    subparsers = _get_subparsers(parser)
+    subparsers = get_subparsers(parser, create=True)
 
     if namespace:
         # make a namespace placeholder and register the commands within it
         command_parser.set_defaults(function=func)
 
 def dispatch(parser, argv=None, add_help_command=True, encoding=None,
-             intercept=False):
+             intercept=False, completion=True, pre_call=None):
     """Parses given list of arguments using given parser, calls the relevant
     function and prints the result.
 
         if `True`, converts first positional argument "help" to a keyword
         argument so that ``help foo`` becomes ``foo --help`` and displays usage
         information for "foo". Default is `True`.
+    :param encoding:
+        Encoding for results. If `None`, it is determined automatically.
+        Default is `None`.
+    :param intercept:
+        If `True`, results are returned as strings. If `False`, results are
+        printed to stdout. Default is `False`.
+    :param completion:
+        If `True`, shell tab completion is enabled. Default is `True`. (You
+        will also need to install it.)
 
     Exceptions are not wrapped and will propagate. The only exception among the
     exceptions is :class:`CommandError` which is interpreted as an expected
     event so the traceback is hidden.
     """
+    if completion:
+        autocomplete(parser)
+
     if argv is None:
         argv = sys.argv[1:]
     if add_help_command:
         # if there were no commands defined for the parser (a possible case)
         raise NotImplementedError('Cannot dispatch without commands')
 
+    if pre_call:
+        pre_call(args)
+
     # try different ways of calling the command; if meanwhile it raises
     # CommandError, return the string representation of that error
     try:
         "Wrapper for :func:`add_commands`."
         return add_commands(self, *args, **kwargs)
 
+    def autocomplete(self):
+        return autocomplete(self)
+
     def dispatch(self, *args, **kwargs):
         "Wrapper for :func:`dispatch`."
         return dispatch(self, *args, **kwargs)
+# -*- coding: utf-8 -*-
+
+import argparse
+
+
+def get_subparsers(parser, create=False):
+    """Returns the :class:`argparse._SupParsersAction` instance for given
+    :class:`ArgumentParser` instance as would have been returned by
+    :meth:`ArgumentParser.add_subparsers`. The problem with the latter is that
+    it only works once and raises an exception on the second attempt, and the
+    public API seems to lack a method to get *existing* subparsers.
+
+    :param create:
+        If `True`, creates the subparser if it does not exist. Default if
+        `False`.
+
+    """
+    # note that ArgumentParser._subparsers is *not* what is returned by
+    # ArgumentParser.add_subparsers().
+    if parser._subparsers:
+        actions = [a for a in parser._actions
+                   if isinstance(a, argparse._SubParsersAction)]
+        assert len(actions) == 1
+        return actions[0]
+    else:
+        if create:
+            return parser.add_subparsers()

scripts/bash_completion.sh

+#!/bin/sh
+#
+# Command completion for bash shell. Works with any Python script as long as
+# it uses Argh as CLI dispatcher. Note that you need to specify the script
+# name. It won't work with just some random file.
+#
+# Put this to your .bashrc:
+#
+#     source /path/to/argh_completion.bash
+#     complete -F _argh_completion PROG
+#
+# ...where PROG should be your script name (e.g. "manage.py").
+#
+_argh_completion()
+{
+    COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \
+                   COMP_CWORD=$COMP_CWORD \
+                   ARGH_AUTO_COMPLETE=1 $1 ) )
+}
 import unittest2 as unittest
 import argparse
 import argh.helpers
-from argh import alias, ArghParser, arg, add_commands, dispatch, plain_signature
+from argh import (
+    alias, ArghParser, arg, add_commands, dispatch, plain_signature
+)
+from argh import completion
 
 
 class DebugArghParser(ArghParser):
         """
         self.assert_cmd_returns('foo-bar foo bar', 'foo\nbar')
 
+
 class ConfirmTestCase(unittest.TestCase):
     def assert_choice(self, choice, expected, **kwargs):
         argh.helpers.raw_input = lambda prompt: choice
             assert isinstance(prompt, str)
         argh.helpers.raw_input = raw_input_mock
         argh.confirm(u'привет')
+
+
+class CompletionTestCase(unittest.TestCase):
+    def setUp(self):
+        "Declare some commands and allocate two namespaces for them"
+        def echo(args):
+            return args
+
+        def load(args):
+            return 'fake load'
+
+        @arg('--format')
+        def dump(args):
+            return 'fake dump'
+
+        self.parser = DebugArghParser()
+        self.parser.add_commands([echo], namespace='silly')
+        self.parser.add_commands([load, dump], namespace='fixtures')
+
+    def assert_choices(self, arg_string, expected):
+        args = arg_string.split()
+        cwords = args
+        cword = len(args) + 1
+        choices = completion._autocomplete(self.parser, cwords, cword)
+        self.assertEqual(' '.join(sorted(choices)), expected)
+
+    def test_root(self):
+        self.assert_choices('', 'fixtures silly')
+
+    def test_root_missing(self):
+        self.assert_choices('xyz', '')
+
+    def test_root_partial(self):
+        self.assert_choices('f', 'fixtures')
+        self.assert_choices('fi', 'fixtures')
+        self.assert_choices('s', 'silly')
+
+    def test_inner(self):
+        self.assert_choices('fixtures', 'dump load')
+        self.assert_choices('silly', 'echo')
+
+    def test_inner_partial(self):
+        self.assert_choices('fixtures d', 'dump')
+        self.assert_choices('fixtures dum', 'dump')
+        self.assert_choices('silly e', 'echo')
+
+    def test_inner_extra(self):
+        self.assert_choices('silly echo foo', '')
+
+    @unittest.expectedFailure
+    def test_inner_options(self):
+        self.assert_choices('fixtures dump', '--format')
+        self.assert_choices('silly echo', 'text')