Repository: JonathonReinhart/scuba
Pull request #33 (2016-01-23 16:30:37)

Add tests for scuba.__main__
These tests exercise scuba using its command line arguments, but within the context of the unit tests, so nosetests is still easily able to get coverage.


Patch:
diff --git a/.travis.yml b/.travis.yml
index 77625b4..8670648 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,3 +1,8 @@
+# We need sudo, in order to install and run Docker.
+sudo: required
+services:
+  - docker
+
 language: python
 python:
   - "2.6"
@@ -13,6 +18,9 @@ matrix:
   allow_failures:
     - python: "nightly"
 
+before_script:
+  - docker version
+
 script:
   - ./run_tests.sh
 
diff --git a/scuba/__main__.py b/scuba/__main__.py
index 3a7901a..a1ed28e 100755
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -19,7 +19,7 @@ import json
 from .constants import *
 from .config import find_config, load_config, ConfigError
 from .etcfiles import *
-from . import filecleanup
+from .filecleanup import FileCleanup
 
 __version__ = '1.4.0'
 
@@ -111,7 +111,7 @@ def get_native_opts():
 def parse_args(argv):
     ap = argparse.ArgumentParser(description='Simple Container-Utilizing Build Apparatus')
     ap.add_argument('-n', '--dry-run', action='store_true')
-    ap.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__)
+    ap.add_argument('-v', '--version', action='version', version='scuba ' + __version__)
     ap.add_argument('-V', '--verbose', action='store_true')
     ap.add_argument('command', nargs=argparse.REMAINDER)
     args = ap.parse_args(argv)
@@ -121,10 +121,14 @@ def parse_args(argv):
 
     return args
 
+
 def main(argv=None):
     args = parse_args(argv)
 
-    filecleanup.skip(args.dry_run)
+    global filecleanup
+    filecleanup = FileCleanup()
+    if not args.dry_run:
+        atexit.register(filecleanup.cleanup)
 
     # top_path is where .scuba.yml is found, and becomes the top of our bind mount.
     # top_rel is the relative path from top_path to the current working directory,
@@ -205,12 +209,18 @@ def main(argv=None):
 
     if args.dry_run:
         appmsg('Exiting for dry run. Temporary files not removed:')
-        for f in filecleanup.files():
+        for f in filecleanup.files:
             print('   ' + f, file=sys.stderr)
         sys.exit(42)
 
     try:
-        rc = subprocess.call(run_args)
+        # Explicitly pass sys.stdout/stderr so they apply to the
+        # child process if overridden (by tests).
+        rc = subprocess.call(
+                args = run_args,
+                stdout = sys.stdout,
+                stderr = sys.stderr,
+                )
     except OSError as e:
         if e.errno == errno.ENOENT:
             appmsg('Failed to execute docker. Is it installed?')
diff --git a/scuba/config.py b/scuba/config.py
index d49f207..dd7e77c 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -93,14 +93,11 @@ def find_config():
         # Traverse up directory hierarchy
         path, rest = os.path.split(path)
         if not rest:
-            break
+            raise ConfigError('{0} not found here or any parent directories'.format(SCUBA_YML))
 
         # Accumulate the relative path back to where we started
         rel = os.path.join(rest, rel)
 
-    raise ConfigError('{0} not found here or any parent directories'.format(SCUBA_YML))
-
-
 
 class ScubaConfig(object):
     def __init__(self, **data):
diff --git a/scuba/filecleanup.py b/scuba/filecleanup.py
index 06ff256..86dd09b 100644
--- a/scuba/filecleanup.py
+++ b/scuba/filecleanup.py
@@ -1,32 +1,26 @@
 import os
 import atexit
 
-__all__ = ['files', 'register', 'skip']
+class FileCleanup(object):
+    def __init__(self):
+        self._files = []
 
-_rmfiles = []
-_skip = False
+    def register(self, path):
+        '''Register a file to be removed at exit'''
+        self._files.append(path)
 
-def register(path):
-    '''Register a file to be removed at exit'''
-    _rmfiles.append(path)
+    @property
+    def files(self):
+        '''Get files registered for cleanup'''
+        return iter(self._files)
 
-def skip(skip=True):
-    '''Don't actually cleanup'''
-    global _skip
-    _skip = skip
+    def cleanup(self):
+        '''Cleanup registered files'''
 
-def files():
-    '''Get files registered for cleanup'''
-    return iter(_rmfiles)
+        for f in self._files:
+            try:
+                os.remove(f)
+            except OSError:
+                pass
 
-def __cleanup():
-    if _skip:
-        return
-
-    for f in _rmfiles:
-        try:
-            os.remove(f)
-        except OSError:
-            pass
-
-atexit.register(__cleanup)
+        self._files = []


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index bf7e954..502545d 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -2,22 +2,17 @@
 from __future__ import print_function
 
 from nose.tools import *
+from .utils import *
 from unittest import TestCase
 
 import logging
 import os
-from os.path import join, normpath
+from os.path import join
 from tempfile import mkdtemp
 from shutil import rmtree
 
 import scuba.config
 
-def assert_paths_equal(a, b):
-    assert_equals(normpath(a), normpath(b))
-
-def assert_seq_equal(a, b):
-    assert_equals(list(a), list(b))
-
 class TestConfig(TestCase):
     def setUp(self):
         self.orig_path = os.getcwd()
diff --git a/tests/test_filecleanup.py b/tests/test_filecleanup.py
new file mode 100644
index 0000000..9cdc452
--- /dev/null
+++ b/tests/test_filecleanup.py
@@ -0,0 +1,71 @@
+from __future__ import print_function
+
+from nose.tools import *
+from .utils import *
+from unittest import TestCase
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+from scuba.filecleanup import FileCleanup
+
+class TestFilecleanup(TestCase):
+
+    @mock.patch('os.remove')
+    def test_files_tracked(self, os_remove_mock):
+        '''FileCleanup.files works'''
+
+        fc = FileCleanup()
+        fc.register('foo.txt')
+        fc.register('bar.bin')
+
+        assert_set_equal(fc.files, ['foo.txt', 'bar.bin'])
+        
+
+    @mock.patch('os.remove')
+    def test_basic_usage(self, os_remove_mock):
+        '''FileCleanup removes one file'''
+
+        fc = FileCleanup()
+        fc.register('foo.txt')
+        fc.cleanup()
+
+        os_remove_mock.assert_any_call('foo.txt') 
+
+    
+    @mock.patch('os.remove')
+    def test_multiple_files(self, os_remove_mock):
+        '''FileCleanup removes multiple files'''
+
+        fc = FileCleanup()
+        fc.register('foo.txt')
+        fc.register('bar.bin')
+        fc.register('/something/snap.crackle')
+        fc.cleanup()
+
+        os_remove_mock.assert_any_call('bar.bin') 
+        os_remove_mock.assert_any_call('foo.txt') 
+        os_remove_mock.assert_any_call('/something/snap.crackle')
+
+
+    @mock.patch('os.remove')
+    def test_multiple_files(self, os_remove_mock):
+        '''FileCleanup ignores os.remove() errors'''
+
+        def os_remove_se(path):
+            if path == 'INVALID':
+                raise OSError('path not found')
+
+        os_remove_mock.side_effect = os_remove_se
+
+        fc = FileCleanup()
+        fc.register('foo.txt')
+        fc.register('bar.bin')
+        fc.register('INVALID')
+        fc.cleanup()
+
+        os_remove_mock.assert_any_call('bar.bin') 
+        os_remove_mock.assert_any_call('foo.txt') 
+
+        assert_set_equal(fc.files, [])
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..649f7dc
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,178 @@
+from __future__ import print_function
+
+from nose.tools import *
+from .utils import *
+from unittest import TestCase
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+import logging
+import os
+import sys
+from tempfile import mkdtemp, TemporaryFile
+from shutil import rmtree
+
+import scuba.__main__ as main
+
+DOCKER_IMAGE = 'debian:8.2'
+
+class TestMain(TestCase, BetterAssertRaisesMixin):
+    def setUp(self):
+        # Run each test in its own temp directory
+        self.orig_path = os.getcwd()
+        self.path = mkdtemp('scubatest')
+        os.chdir(self.path)
+        logging.info('Temp path: ' + self.path)
+
+
+    def tearDown(self):
+        # Restore the working dir and cleanup the temp one
+        rmtree(self.path)
+        self.path = None
+        os.chdir(self.orig_path)
+        self.orig_path = None
+
+
+    def run_scuba(self, args, exp_retval=0):
+        '''Run scuba, checking its return value
+
+        Returns scuba/docker stdout data.
+        '''
+
+        # Capture both scuba and docker's stdout/stderr,
+        # just as the user would see it.
+        # Also mock atexit.register(), so we can simulate file cleanup.
+
+        atexit_funcs = []
+        def atexit_reg(cb, *args, **kw):
+            atexit_funcs.append((cb, args, kw))
+
+        with TemporaryFile(prefix='scubatest-stdout', mode='w+t') as stdout:
+            with TemporaryFile(prefix='scubatest-stderr', mode='w+t') as stderr:
+                with mock.patch('atexit.register', side_effect=atexit_reg) as atexit_reg_mock:
+                    old_stdout = sys.stdout
+                    old_stderr = sys.stderr
+
+                    sys.stdout = stdout
+                    sys.stderr = stderr
+
+                    try:
+                        # Call scuba's main(), and expect it to exit() with a given return code.
+                        exc = self.assertRaises2(SystemExit, main.main, argv = args)
+                        assert_equal(exp_retval, exc.args[0])
+
+                        stdout.seek(0)
+                        stderr.seek(0)
+                        return stdout.read(), stderr.read()
+
+                    finally:
+                        sys.stdout = old_stdout
+                        sys.stderr = old_stderr
+                        for f, args, kw in atexit_funcs:
+                            f(*args, **kw)
+
+
+    def test_basic(self):
+        '''Verify basic scuba functionality'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: {0}\n'.format(DOCKER_IMAGE))
+
+        args = ['/bin/echo', '-n', 'my output']
+        out, _ = self.run_scuba(args)
+
+        assert_str_equalish('my output', out)
+
+
+    def test_config_error(self):
+        '''Verify config errors are handled gracefully'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('invalid_key: is no good\n')
+
+        # ConfigError -> exit(128)
+        self.run_scuba([], 128)
+
+
+    def test_version(self):
+        '''Verify scuba prints its version for -v'''
+
+        out, err = self.run_scuba(['-v'])
+
+
+        # Argparse in Python < 3.4 printed version to stderr, but
+        # changed that to stdout in 3.4. We don't care where it goes.
+        # https://bugs.python.org/issue18920
+        check = out or err
+
+        assert_startswith(check, 'scuba')
+
+        ver = check.split()[1]
+        assert_equal(ver, main.__version__)
+
+
+    def test_no_docker(self):
+        '''Verify scuba gracefully handles docker not being installed'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: {0}\n'.format(DOCKER_IMAGE))
+
+        args = ['/bin/echo', '-n', 'my output']
+
+        old_PATH = os.environ['PATH']
+        os.environ['PATH'] = ''
+
+        try:
+            _, err = self.run_scuba(args, 2)
+        finally:
+            os.environ['PATH'] = old_PATH
+
+    @mock.patch('subprocess.call')
+    def test_dry_run(self, subproc_call_mock):
+        '''Verify scuba handles --dry-run and --verbose'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: {0}\n'.format(DOCKER_IMAGE))
+
+        args = ['--dry-run', '--verbose', '/bin/false']
+
+        _, err = self.run_scuba(args, 42)
+
+        assert_false(subproc_call_mock.called)
+
+        #TODO: Assert temp files are not cleaned up?
+
+
+    def test_args(self):
+        '''Verify scuba handles cmdline args'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: {0}\n'.format(DOCKER_IMAGE))
+
+        with open('test.sh', 'w') as f:
+            f.write('#!/bin/sh\n')
+            f.write('for a in "$@"; do echo $a; done\n')
+        make_executable('test.sh')
+
+        lines = ['here', 'are', 'some args']
+
+        out, _ = self.run_scuba(['./test.sh'] + lines)
+
+        assert_seq_equal(out.splitlines(), lines)
+
+
+    def test_created_file_ownership(self):
+        '''Verify files created under scuba have correct ownership'''
+
+        with open('.scuba.yml', 'w') as f:
+            f.write('image: {0}\n'.format(DOCKER_IMAGE))
+
+        filename = 'newfile.txt'
+
+        self.run_scuba(['/bin/touch', filename])
+
+        st = os.stat(filename)
+        assert_equal(st.st_uid, os.getuid())
+        assert_equal(st.st_gid, os.getgid())
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..28466d0
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,55 @@
+import os
+from nose.tools import *
+from os.path import normpath
+
+def assert_set_equal(a, b):
+    assert_equal(set(a), set(b))
+
+def assert_seq_equal(a, b):
+    assert_equals(list(a), list(b))
+
+def assert_paths_equal(a, b):
+    assert_equals(normpath(a), normpath(b))
+
+def assert_str_equalish(exp, act):
+    exp = str(exp).strip()
+    act = str(act).strip()
+    assert_equal(exp, act)
+
+def assert_startswith(s, prefix):
+    s = str(s)
+    prefix = str(prefix)
+    if not s.startswith(prefix):
+        raise AssertionError('"{0}" does not start with "{1}"'
+                .format(escape_str(s), prefix))
+
+def escape_str(s):
+    # Python 3 won't let us use s.encode('string_escape') :-(
+    replacements = [
+        ('\a', '\\a'),
+        ('\b', '\\b'),
+        ('\f', '\\f'),
+        ('\n', '\\n'),
+        ('\r', '\\r'),
+        ('\t', '\\t'),
+        ('\v', '\\v'),
+    ]
+
+    for r in replacements:
+        s = s.replace(*r)
+    return s
+
+def make_executable(path):
+    mode = os.stat(path).st_mode
+    mode |= (mode & 0o444) >> 2    # copy R bits to X
+    os.chmod(path, mode)
+
+class BetterAssertRaisesMixin(object):
+    def assertRaises2(self, exc_type, func, *args, **kwargs):
+        try:
+            func(*args, **kwargs)
+        except exc_type as e:
+            return e
+        else:
+            self.fail('"{0}" was expected to throw "{1}" exception'
+                          .format(func.__name__, exception_type.__name__))