Repository: JonathonReinhart/scuba
Pull request #69 (2016-07-29 02:39:00)

Implement bash completion script
See #46

codecov-io: ## [Current coverage](https://codecov.io/gh/JonathonReinhart/scuba/pull/69?src=pr) is 95.41% (diff: 79.41%)
> Merging [#69](https://codecov.io/gh/JonathonReinhart/scuba/pull/69?src=pr) into [master](https://codecov.io/gh/JonathonReinhart/scuba/branch/master?src=pr) will decrease coverage by **2.84%**


```diff
@@             master        #69   diff @@
==========================================
  Files             7          8     +1   
  Lines           402        436    +34   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits            395        416    +21   
- Misses            7         20    +13   
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [b5d7813...95c49e9](https://codecov.io/gh/JonathonReinhart/scuba/compare/b5d78131de6f0257a6fe7caa5ed7f7a187d17f78...95c49e918a16b5dd68883f3f0541f8d422f57ff4?src=pr)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ca6a18..3633aee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,14 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 - Added support for enhanced aliases (#67)
 - Added support for per-alias image specification (#68)
+- Add bash completion support (#69)
 
 ### Changed
 - All ancillary files are bind-mounted via single temp dir
 - Hook scripts are moved to hooks/ subdirectory
 - User commands always executed via shell (#66)
 - Top-level directory mounted at same path in container (#70)
+- Alias names cannot contain spaces
 
 
 ## [1.7.0] - 2016-05-19
diff --git a/bash_completion/scuba b/bash_completion/scuba
new file mode 100644
index 0000000..aeb29a7
--- /dev/null
+++ b/bash_completion/scuba
@@ -0,0 +1,37 @@
+#!bash
+#
+# bash completion for scuba
+# https://github.com/JonathonReinhart/scuba
+#
+# Copy this file to /etc/bash_completion.d 
+
+_scuba()
+{ 
+    COMPREPLY=()
+
+    # Look at all previous words, skipping command itself
+    local i
+	for ((i = 1; i < ${COMP_CWORD}; i++)); do
+		word="${COMP_WORDS[i]}"
+        if [[ $word != -* ]] ; then
+            # User has already entered a non-option;
+            # there's nothing we can suggest
+            return 0
+        fi
+	done
+
+    local cur="${COMP_WORDS[COMP_CWORD]}"
+
+    # Is the user typing an option?
+    if [[ ${cur} == -* ]] ; then
+        local opts=$(scuba --list-available-options)
+        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
+        return 0
+    fi
+
+    # The only thing we can suggest here is an alias
+    local aliases="$(scuba --list-aliases | tail -n+2 | awk -F'\t' '{print $1 }')"
+    COMPREPLY=( $(compgen -W "${aliases}" -- ${cur}) )
+}
+
+complete -F _scuba scuba
diff --git a/scuba/__main__.py b/scuba/__main__.py
index 400c896..35bfccb 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -12,12 +12,9 @@ import itertools
 import argparse
 import tempfile
 import shutil
-try:
-    from StringIO import StringIO
-except ImportError:
-    # Python 3
-    from io import StringIO
 
+from .cmdlineargs import *
+from .compat import File, StringIO
 from .constants import *
 from .config import find_config, load_config, ConfigError
 from .utils import *
@@ -50,6 +47,10 @@ def parse_scuba_args(argv):
     ap.add_argument('-d', '--docker-arg', dest='docker_args', action='append',
             type=lambda x: shlex.split(x), default=[],
             help="Pass additional arguments to 'docker run'")
+    ap.add_argument('--list-aliases', action='store_true',
+            help=argparse.SUPPRESS)
+    ap.add_argument('--list-available-options', action=ListOptsAction,
+            help=argparse.SUPPRESS)
     ap.add_argument('-n', '--dry-run', action='store_true',
             help="Don't actually invoke docker; just print the docker cmdline")
     ap.add_argument('-r', '--root', action='store_true',
@@ -88,6 +89,10 @@ class ScubaDive(object):
 
         self.__locate_scubainit()
         self.__load_config()
+
+
+    def prepare(self):
+        '''Prepare to run the docker command'''
         self.__make_scubadir()
 
         if self.is_remote_docker:
@@ -277,18 +282,7 @@ class ScubaDive(object):
         # Make any directories required
         mkdir_p(os.path.dirname(path))
 
-        try:
-            # Python 2
-            # open() returns builtin file object which has no __dict__
-            class ScubaDirFile(file):
-                pass
-            op = ScubaDirFile
-        except NameError:
-            # Python 3
-            # 'file' type removed, but open() returns _io.TextIOWrapper which has __dict__
-            op = open
-
-        f = op(path, mode)
+        f = File(path, mode)
         f.container_path = os.path.join(self.__scubadir_contpath, name)
         return f
 
@@ -350,7 +344,15 @@ def run_scuba(scuba_args):
         verbose = scuba_args.verbose
         )
 
+    if scuba_args.list_aliases:
+        print('ALIAS\tIMAGE')
+        for name in sorted(dive.config.aliases):
+            alias = dive.config.aliases[name]
+            print('{0}\t{1}'.format(alias.name, alias.image or dive.config.image))
+        return
+
     try:
+        dive.prepare()
         run_args = dive.get_docker_cmdline()
 
         if g_verbose or scuba_args.dry_run:
@@ -387,7 +389,7 @@ def main(argv=None):
     scuba_args = parse_scuba_args(argv)
 
     try:
-        rc = run_scuba(scuba_args)
+        rc = run_scuba(scuba_args) or 0
         sys.exit(rc)
     except ScubaError as e:
         appmsg(str(e))
diff --git a/scuba/cmdlineargs.py b/scuba/cmdlineargs.py
new file mode 100644
index 0000000..ccbe34a
--- /dev/null
+++ b/scuba/cmdlineargs.py
@@ -0,0 +1,27 @@
+import argparse
+import sys
+
+__all__ = ['ListOptsAction']
+
+class ListOptsAction(argparse.Action):
+    def __init__(self, option_strings, dest, nargs=None, **kwargs):
+        super(ListOptsAction, self).__init__(option_strings, dest, nargs=0, **kwargs)
+
+    def __call__(self, parser, namespace, values, option_string):
+        def get_long_opts():
+            for act in parser._actions:
+                # These aren't shown in help; don't auto-complete them either
+                if act.help == argparse.SUPPRESS:
+                    continue
+
+                # These aren't options
+                if len(act.option_strings) == 0:
+                    continue
+
+                # Prefer long opts, but show short if there is no long version
+                longs = list(filter(lambda o: o.startswith('--'), act.option_strings))
+                yield longs[0] if longs else act.option_strings[0]
+
+        opts = sorted(get_long_opts(), key=lambda s: s.strip('-'))
+        print('\n'.join(opts))
+        sys.exit(0)
diff --git a/scuba/compat.py b/scuba/compat.py
new file mode 100644
index 0000000..2cda4af
--- /dev/null
+++ b/scuba/compat.py
@@ -0,0 +1,26 @@
+# Python 2/3 compatibility
+
+__all__ = ['File', 'StringIO']
+
+
+# StringIO
+# Python 3 moved it to the 'io' package
+try:
+    from StringIO import StringIO
+except ImportError:
+    from io import StringIO
+
+
+# File
+# A replacement for 'open' that returns a new-style object,
+# so additional attributes can be set on it.
+try:
+    # Python 2
+    # open() returns builtin file object which has no __dict__
+    class File(file):
+        pass
+except NameError:
+    # Python 3
+    # 'file' type removed, but open() returns _io.TextIOWrapper
+    # which has a __dict__
+    File = open
diff --git a/scuba/config.py b/scuba/config.py
index 07bed29..4255420 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -172,6 +172,8 @@ class ScubaConfig(object):
         self._aliases = {}
 
         for name, node in data.get('aliases', {}).items():
+            if ' ' in name:
+                raise ConfigError('Alias names cannot contain spaces')
             self._aliases[name] = ScubaAlias.from_dict(name, node)
 
 
diff --git a/setup.py b/setup.py
index 1708fe9..de1c4e8 100644
--- a/setup.py
+++ b/setup.py
@@ -28,6 +28,9 @@ setup(
             'scubainit',
         ],
     },
+    data_files = [
+        ('/etc/bash_completion.d', ['bash_completion/scuba']),
+    ],
     zip_safe = False,   # http://stackoverflow.com/q/24642788/119527
     entry_points = {
         'console_scripts': [


Test patch:
diff --git a/tests/test_cmdlineargs.py b/tests/test_cmdlineargs.py
new file mode 100644
index 0000000..ba3ba57
--- /dev/null
+++ b/tests/test_cmdlineargs.py
@@ -0,0 +1,41 @@
+ # coding=utf-8
+from __future__ import print_function
+from unittest import TestCase
+from .utils import RedirStd
+
+import sys
+import argparse
+from scuba.compat import StringIO
+
+# UUT
+from scuba.cmdlineargs import ListOptsAction
+
+class TestConfig(TestCase):
+
+    def __test_ListOptsAction(self, ap, expected_opts):
+        # Verify that passing --list-opts will cause all non-suppressed
+        # command-line options to be printed to stdout, and sys.exit is called.
+        with RedirStd(stdout=StringIO()) as r:
+            ap.add_argument('--list-opts', action=ListOptsAction, help=argparse.SUPPRESS)
+            self.assertRaises(SystemExit, ap.parse_args, ['--list-opts'])
+
+        opts = r.stdout.getvalue().split()
+        self.assertEqual(expected_opts, opts)
+
+    def test_ListOptsAction(self):
+        ap = argparse.ArgumentParser()
+        ap.add_argument('-n')
+        ap.add_argument('-z', '--zzz')
+        ap.add_argument('-b', '--bbb')
+        ap.add_argument('-a', '--aaa')
+
+        # We expect to see all options, including automatically-added --help.
+        # We expect long options if they are present, or short options otherwise.
+        expected = ['--aaa', '--bbb', '--help', '-n', '--zzz']
+        self.__test_ListOptsAction(ap, expected)
+
+    def test_ListOptsAction_no_opts(self):
+        ap = argparse.ArgumentParser()
+        ap.add_argument('foo')
+        expected = ['--help']
+        self.__test_ListOptsAction(ap, expected)
diff --git a/tests/test_compat.py b/tests/test_compat.py
new file mode 100644
index 0000000..3147ba7
--- /dev/null
+++ b/tests/test_compat.py
@@ -0,0 +1,13 @@
+ # coding=utf-8
+from __future__ import print_function
+from unittest import TestCase
+
+class TestCompat(TestCase):
+    def test_StringIO(self):
+        from scuba.compat import StringIO
+        s = StringIO()
+
+    def test_File(self):
+        from scuba.compat import File
+        f = File('/etc/passwd', 'rt')
+        f.we_can_add_new_attrs = True
diff --git a/tests/test_config.py b/tests/test_config.py
index 6be5b4b..81f4102 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -118,7 +118,14 @@ class TestConfig(TestCase):
         assert_seq_equal(config.aliases['foo'].script, [['bar']])
         assert_seq_equal(config.aliases['snap'].script, [['crackle', 'pop']])
 
+    def test_load_config__no_spaces_in_aliases(self):
+        '''load_config refuses spaces in aliases'''
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: busybox\n')
+            f.write('aliases:\n')
+            f.write('  this has spaces: whatever\n')
 
+        assert_raises(scuba.config.ConfigError, scuba.config.load_config, '.scuba.yml')
 
     def test_load_config_image_from_yaml(self):
         '''load_config loads a config using !from_yaml'''
diff --git a/tests/test_main.py b/tests/test_main.py
index 474af21..cb4e095 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -76,7 +76,7 @@ class TestMain(TestCase):
                         try:
                             main.main(argv = args)
                         except SystemExit as sysexit:
-                            retcode = sysexit.args[0]
+                            retcode = sysexit.code
                         else:
                             retcode = 0
 
@@ -388,3 +388,34 @@ class TestMain(TestCase):
     def test_root_hook(self):
         '''Verify root hook executes as root'''
         self._test_one_hook('root', 0, 0)
+
+
+    ############################################################################
+    # Misc
+    def test_list_aliases(self):
+        '''Verify --list-aliases works'''
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: default\n')
+            f.write('aliases:\n')
+            f.write('  aaa:\n')
+            f.write('    image: aaa_image\n')
+            f.write('    script:\n')
+            f.write('      - foo\n')
+            f.write('  bbb:\n')
+            f.write('    script:\n')
+            f.write('      - foo\n')
+            f.write('  ccc: foo\n')
+
+        expected = (
+            ('ALIAS',   'IMAGE'),
+            ('aaa',     'aaa_image'),
+            ('bbb',     'default'),
+            ('ccc',     'default'),
+        )
+
+        out, err = self.run_scuba(['--list-aliases'])
+        lines = out.splitlines()
+
+        assert_equal(len(expected), len(lines))
+        for i in range(len(expected)):
+            assert_seq_equal(expected[i], lines[i].split('\t'))
diff --git a/tests/utils.py b/tests/utils.py
index d20faa5..1dfaa74 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,4 +1,5 @@
 import os
+import sys
 from nose.tools import *
 from os.path import normpath
 import tempfile
@@ -72,3 +73,30 @@ class InTempDir(object):
         os.chdir(self.orig_path)
         if self.delete:
             shutil.rmtree(self.temp_path)
+
+
+class RedirStd(object):
+    def __init__(self, stdout=None, stderr=None):
+        self.stdout = stdout
+        self.stderr = stderr
+
+        self.orig_stdout = None
+        self.orig_stderr = None
+
+    def __enter__(self):
+        if self.stdout:
+            self.orig_stdout = sys.stdout
+            sys.stdout = self.stdout
+
+        if self.stderr:
+            self.orig_stderr = sys.stderr
+            sys.stderr = self.stderr
+
+        return self
+
+    def __exit__(self, *exc_info):
+        if self.orig_stdout:
+            sys.stdout = self.orig_stdout
+
+        if self.orig_stderr:
+            sys.stderr = self.orig_stderr