Repository: ethanrowe/python-mandrel

Issue:
Python 3 support
I'd like to port treehouse, which uses Mandrel, to python 3, but ran into problems because Mandrel isn't python 3 compatible.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index d81b6d1..edf9bd2 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,4 +2,7 @@ language: python
 python:
   - "2.6"
   - "2.7"
-script: python setup.py test
+  - "3.6"
+script:
+- python setup.py test
+
diff --git a/changelog.md b/changelog.md
index 7a665bc..4a85dcd 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,4 +1,8 @@
-2.0.0
+0.3.0
+
+* Python 3 support
+
+0.2.0
 
 * mandrel-runner uses return value of callable as the exit code.
 
diff --git a/mandrel/bootstrap.py b/mandrel/bootstrap.py
index f405605..45a2cfa 100644
--- a/mandrel/bootstrap.py
+++ b/mandrel/bootstrap.py
@@ -47,7 +47,7 @@ def find_logging_configuration():
     """
     for path in util.find_files(LOGGING_CONFIG_BASENAME, SEARCH_PATHS, matches=1):
         return path
-    raise exception.UnknownConfigurationException, "Cannot find logging configuration file(s) '%s'" % LOGGING_CONFIG_BASENAME
+    raise exception.UnknownConfigurationException("Cannot find logging configuration file(s) '%s'" % LOGGING_CONFIG_BASENAME)
 
 DEFAULT_LOGGING_CALLBACK = initialize_simple_logging
 DISABLE_EXISTING_LOGGERS = True
@@ -109,7 +109,7 @@ def _find_bootstrap_base():
     while not os.path.isfile(os.path.join(current, __BOOTSTRAP_BASENAME)):
         parent = os.path.dirname(current)
         if parent == current:
-            raise exception.MissingBootstrapException, 'Cannot find %s file in directory hierarchy' % __BOOTSTRAP_BASENAME
+            raise exception.MissingBootstrapException('Cannot find %s file in directory hierarchy' % __BOOTSTRAP_BASENAME)
         current = parent
 
     return current, os.path.join(current, __BOOTSTRAP_BASENAME)
diff --git a/mandrel/config/core.py b/mandrel/config/core.py
index c75ca36..d2bb7ed 100644
--- a/mandrel/config/core.py
+++ b/mandrel/config/core.py
@@ -81,7 +81,7 @@ def find_configuration_file(name):
     """
     for path in find_configuration_files(name):
         return path
-    raise exception.UnknownConfigurationException, "No configuration file found for name '%s'" % name
+    raise exception.UnknownConfigurationException("No configuration file found for name '%s'" % name)
 
 def get_loader(path):
     """Gets the configuration loader for path according to file extension.
@@ -98,7 +98,7 @@ def get_loader(path):
         fullext = '.' + ext
         if path[-len(fullext):] == fullext:
             return loader
-    raise exception.UnknownConfigurationException, "No configuration loader found for path '%s'" % path
+    raise exception.UnknownConfigurationException("No configuration loader found for path '%s'" % path)
 
 def load_configuration_file(path):
     """Loads the configuration at path and returns it.
@@ -267,7 +267,7 @@ class Configuration(object):
             except AttributeError:
                 pass
 
-        raise AttributeError, 'No such attribute: %s' % attribute
+        raise AttributeError('No such attribute: %s' % attribute)
 
     def __getattr__(self, attr):
         return self.chained_get(attr)
diff --git a/mandrel/runner.py b/mandrel/runner.py
index 8a01ccf..5349ebd 100644
--- a/mandrel/runner.py
+++ b/mandrel/runner.py
@@ -109,7 +109,12 @@ class ScriptRunner(AbstractRunner):
     def execute_script(self, script):
         glb = globals()
         glb.update(__name__='__main__', __file__=script)
-        return execfile(script, glb)
+        if sys.version_info[0] > 2:
+            import builtins
+            execf = getattr(builtins, 'exec')
+            return execf(compile(open(script).read(), script, 'exec'), glb)
+        else:
+            return execfile(script, glb)
 
     def execute(self, target, args):
         self.prepare_environment(args)
diff --git a/mandrel/util.py b/mandrel/util.py
index fbc17e0..4fe9bbc 100644
--- a/mandrel/util.py
+++ b/mandrel/util.py
@@ -1,5 +1,13 @@
 import os
 import re
+import sys
+
+
+if sys.version_info[0] == 2:
+    string_type = basestring
+else:
+    string_type = str
+
 
 class TransformingList(object):
     __slots__ = ('_list', '_transformer')
@@ -9,7 +17,10 @@ class TransformingList(object):
         self._transformer = transformer
 
     def __setitem__(self, i, y):
-        self._list[i] = self._transformer(y)
+        if isinstance(i, slice):
+            self._list[i] = (self._transformer(v) for v in y)
+        else:
+            self._list[i] = self._transformer(y)
 
     def __setslice__(self, i, j, y):
         self._list[i:j] = (self._transformer(v) for v in y)
@@ -72,7 +83,7 @@ def find_files(name_or_names, paths, matches=None):
     yielded value is the full path to a matching file.
     """
 
-    if isinstance(name_or_names, basestring):
+    if isinstance(name_or_names, string_type):
         name_or_names = [name_or_names]
 
     if matches is None:
@@ -138,9 +149,9 @@ def convention_loader(format_string):
     not contain one and only one '%s' within it.
     """
     if not format_string:
-        raise TypeError, 'format_string cannot be blank'
+        raise TypeError('format_string cannot be blank')
     if re.findall('%.', format_string) != ['%s']:
-        raise TypeError, 'format_string must contain one and only one "%s" token'
+        raise TypeError('format_string must contain one and only one "%s" token')
 
     def func(name):
         return get_by_fqn(format_string % name)
diff --git a/setup.py b/setup.py
index 791c3b5..4cea793 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@ import os
 
 setup(
     name = "mandrel",
-    version = "0.2.0",
+    version = "0.3.0",
     author = "Ethan Rowe",
     author_email = "ethan@the-rowes.com",
     description = ("Provides bootstrapping for sane configuration management"),
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..fbbf9e5
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,12 @@
+# Tox (https://tox.readthedocs.io/) is a tool for running tests
+# in multiple virtualenvs. This configuration file will run the
+# test suite on all supported python versions. To use it, "pip install tox"
+# and then run "tox" from this directory.
+
+[tox]
+envlist = py26,py27,py36
+
+[testenv]
+commands = {envpython} setup.py test
+deps =
+


Test patch:
diff --git a/mandrel/test/config/configuration_class_test.py b/mandrel/test/config/configuration_class_test.py
index 7294f72..519ecc1 100644
--- a/mandrel/test/config/configuration_class_test.py
+++ b/mandrel/test/config/configuration_class_test.py
@@ -1,8 +1,9 @@
-import unittest
 import mock
+
 import mandrel.config
-from mandrel.test import utils
 from mandrel import exception
+from mandrel.test import utils
+
 
 class TestConfigurationClass(utils.TestCase):
     @mock.patch('mandrel.config.core.get_configuration')
@@ -21,7 +22,7 @@ class TestConfigurationClass(utils.TestCase):
             result = mandrel.config.core.Configuration.get_logger_name('some.nested.name')
             self.assertEqual(mock_name + '.some.nested.name', result)
 
-    @mock.patch('mandrel.bootstrap')
+    @mock.patch('mandrel.bootstrap', create=True)
     @mock.patch('mandrel.config.core.Configuration.get_logger_name')
     def testGetLogger(self, get_logger_name, bootstrap):
         mock_name = str(mock.Mock(name='AnotherMockConfigurationName'))
@@ -45,7 +46,7 @@ class TestConfigurationClass(utils.TestCase):
         self.assertEqual(config, c.configuration)
         self.assertEqual((), c.chain)
 
-        chain = tuple(mock.Mock(name='Chain%d' % x) for x in xrange(3))
+        chain = tuple(mock.Mock(name='Chain%d' % x) for x in range(3))
         c = mandrel.config.core.Configuration(config, *chain)
         self.assertEqual(config, c.configuration)
         self.assertEqual(chain, c.chain)
@@ -136,7 +137,7 @@ class TestConfigurationClass(utils.TestCase):
         self.assertEqual(loader.return_value, c.configuration)
         self.assertEqual((), c.chain)
 
-        chain = tuple(mock.Mock() for x in xrange(5))
+        chain = tuple(mock.Mock() for x in range(5))
         c = mandrel.config.core.Configuration.get_configuration(*chain)
         self.assertEqual(loader.return_value, c.configuration)
         self.assertEqual(chain, c.chain)
diff --git a/mandrel/test/config/loader_functionality_test.py b/mandrel/test/config/loader_functionality_test.py
index e2ab975..b88ceae 100644
--- a/mandrel/test/config/loader_functionality_test.py
+++ b/mandrel/test/config/loader_functionality_test.py
@@ -1,13 +1,20 @@
-import contextlib
 import unittest
+
 import mock
+
 import mandrel
 from mandrel import exception
-from mandrel.test import utils
+
+try:
+    # python 3 compatibility
+    from importlib import reload
+except ImportError:
+    pass
+
 
 def scenario(func):
     def wrapper(*a, **kw):
-        with mock.patch('mandrel.bootstrap') as bootstrap:
+        with mock.patch('mandrel.bootstrap', create=True) as bootstrap:
             if hasattr(mandrel, 'config'):
                 reload(mandrel.config.core)
                 reload(mandrel.config)
@@ -48,7 +55,7 @@ class TestConfigLoaderFunctionality(unittest.TestCase):
     def testFindConfigurationFiles(self):
         with mock.patch('mandrel.util.find_files') as find_files:
             with mock.patch('mandrel.config.core.get_possible_basenames') as get_possible_basenames:
-                exts = [mock.Mock(name='Extension%d' % x) for x in xrange(3)]
+                exts = [mock.Mock(name='Extension%d' % x) for x in range(3)]
                 mandrel.config.core.LOADERS = [(ext, mock.Mock(name='Reader')) for ext in exts]
                 mandrel.bootstrap.SEARCH_PATHS = mock.Mock()
                 name = mock.Mock(name='FileBase')
@@ -60,7 +67,7 @@ class TestConfigLoaderFunctionality(unittest.TestCase):
     @scenario
     def testFindConfigurationFileWithMatch(self):
         with mock.patch('mandrel.config.core.find_configuration_files') as find_configuration_files:
-            paths = [mock.Mock(name='Path%d' % x) for x in xrange(10)]
+            paths = [mock.Mock(name='Path%d' % x) for x in range(10)]
             find_configuration_files.side_effect = lambda x: iter(paths)
             name = mock.Mock(name='SomeBase')
             result = mandrel.config.core.find_configuration_file(name)
@@ -76,11 +83,11 @@ class TestConfigLoaderFunctionality(unittest.TestCase):
 
     @scenario
     def testGetLoader(self):
-        exts = [str(mock.Mock(name='Extension%d' % x)) for x in xrange(3)]
-        loaders = [mock.Mock(name='Loader%d' % x) for x in xrange(len(exts))]
-        mandrel.config.core.LOADERS = zip(exts, loaders)
+        exts = [str(mock.Mock(name='Extension%d' % x)) for x in range(3)]
+        loaders = [mock.Mock(name='Loader%d' % x) for x in range(len(exts))]
+        mandrel.config.core.LOADERS = list(zip(exts, loaders))
 
-        for i in xrange(len(exts)):
+        for i in range(len(exts)):
             ext, loader = mandrel.config.core.LOADERS[i]
             path = '%s.%s' % (mock.Mock(name='someFile'), ext)
             result = mandrel.config.core.get_loader(path)
diff --git a/mandrel/test/runner_test.py b/mandrel/test/runner_test.py
index 9a809ec..a84b305 100644
--- a/mandrel/test/runner_test.py
+++ b/mandrel/test/runner_test.py
@@ -31,6 +31,12 @@ def scenario(*opts, **driver_opt):
 
 KNOWN_PATH = os.path.realpath('')
 
+if sys.version_info[0] > 2:
+    builtin_name = 'builtins'
+else:
+    builtin_name = '__builtin__'
+
+
 class TestRunner(unittest.TestCase):
     @scenario('-s', 'foo:bar:bah:', 'gloof', 'glof', 'floo', ensure_target=False)
     def testSearchPathSet(self, path):
@@ -74,7 +80,7 @@ class TestRunner(unittest.TestCase):
     @mock.patch.object(runner.AbstractRunner, 'process_options')
     @mock.patch.object(runner.AbstractRunner, 'execute')
     def testOrderOfOperations(self, path, execute, process_options):
-        mocks = [mock.Mock('positional%d' % x) for x in xrange(4)]
+        mocks = [mock.Mock('positional%d' % x) for x in range(4)]
         target = mocks.pop(0)
         process_options.return_value = (target, mocks)
         runner.AbstractRunner().run()
@@ -90,7 +96,7 @@ class TestRunner(unittest.TestCase):
     @scenario()
     def testImporting(self, _):
         obj = runner.AbstractRunner()
-        with mock.patch('__builtin__.__import__') as importer:
+        with mock.patch(builtin_name + '.__import__') as importer:
             self.assertEqual(importer.return_value.bootstrap, obj.bootstrapper)
             importer.assert_called_once_with('mandrel.bootstrap')
 
@@ -102,18 +108,18 @@ class TestRunner(unittest.TestCase):
     @mock.patch('mandrel.util.get_by_fqn')
     def testCallableRunner(self, get_by_fqn):
         target = str(mock.Mock(name='MockTarget'))
-        opts = [str(mock.Mock(name='Arg%d' % x)) for x in xrange(3)]
+        opts = [str(mock.Mock(name='Arg%d' % x)) for x in range(3)]
         result = runner.CallableRunner().execute(target, opts)
         get_by_fqn.assert_called_once_with(target)
         get_by_fqn.return_value.assert_called_once_with(opts)
         self.assertEqual(get_by_fqn.return_value.return_value, result)
 
-    @mock.patch('__builtin__.globals')
+    @mock.patch(builtin_name + '.globals')
     @mock.patch('sys.argv', new=['foo', 'bar', 'bah'])
-    @mock.patch('__builtin__.execfile')
+    @mock.patch('mandrel.runner.ScriptRunner.execute_script')
     def testScriptRunner(self, mock_exec, mock_globals):
         target = str(mock.Mock(name='MockTarget'))
-        opts = [str(mock.Mock(name='Arg%d' % x)) for x in xrange(3)]
+        opts = [str(mock.Mock(name='Arg%d' % x)) for x in range(3)]
         glb = {'foo': mock.Mock(), 'bar': mock.Mock(), '__file__': mock.Mock(), '__name__': mock.Mock()}
         mock_globals.side_effect = lambda: dict(glb)
         exp = dict(glb)
@@ -121,7 +127,7 @@ class TestRunner(unittest.TestCase):
         exp['__name__'] = '__main__'
 
         result = runner.ScriptRunner().execute(target, opts)
-        mock_exec.assert_called_once_with(target, exp)
+        mock_exec.assert_called_once_with(target)
         self.assertEqual(mock_exec.return_value, result)
         # Should add args at sys.argv[1:]
         self.assertEqual(['foo'] + opts, sys.argv)
diff --git a/mandrel/test/util/file_finder_test.py b/mandrel/test/util/file_finder_test.py
index 47f03de..5bb6065 100644
--- a/mandrel/test/util/file_finder_test.py
+++ b/mandrel/test/util/file_finder_test.py
@@ -14,7 +14,7 @@ def scenario(**files_to_levels):
             levels.append(b)
             with utils.tempdir() as c:
                 levels.append(c)
-                for name, dirs in files_to_levels.items():
+                for name, dirs in list(files_to_levels.items()):
                     for level in dirs:
                         with open(os.path.join(levels[level], name), 'w') as f:
                             f.write(str(level))
@@ -32,21 +32,21 @@ def get_level(path):
 class TestFileFinder(unittest.TestCase):
     def testSingleFindOneMatch(self):
         with scenario(**{'a.txt': (0, 1, 2), 'b.foo': (1, 2), 'c.bar': (2,)}) as dirs:
-            for name, level in {'a.txt': 0, 'b.foo': 1, 'c.bar': 2}.items():
+            for name, level in list({'a.txt': 0, 'b.foo': 1, 'c.bar': 2}.items()):
                 result = tuple(util.find_files(name, dirs, matches=1))
                 self.assertEqual(1, len(result))
                 self.assertEqual(level, get_level(result[0]))
 
     def testSingleFindTwoMatch(self):
         with scenario(**{'0.x': (0,), 'a.txt': (0, 1, 2), 'b.foo': (1, 2), 'c.bar': (2,)}) as dirs:
-            for name, levels in {'0.x': (0,), 'a.txt': (0, 1), 'b.foo': (1, 2), 'c.bar': (2,)}.items():
+            for name, levels in list({'0.x': (0,), 'a.txt': (0, 1), 'b.foo': (1, 2), 'c.bar': (2,)}.items()):
                 got = tuple(get_level(r) for r in util.find_files(name, dirs, matches=2))
                 self.assertEqual(levels, got)
 
     def testSingleFindMultiMatch(self):
         mapping = {'0.x': (0,), 'a.txt': (0, 1), 'b.blah': (0, 1, 2), 'c.pork': (1, 2), 'd.plonk': (1,), 'e.sporks': (2,)}
         with scenario(**mapping) as dirs:
-            for name, levels in mapping.items():
+            for name, levels in list(mapping.items()):
                 got = tuple(get_level(r) for r in util.find_files(name, dirs))
                 self.assertEqual(levels, got)
 
diff --git a/mandrel/test/util/loaders_test.py b/mandrel/test/util/loaders_test.py
index 698478d..e5b412f 100644
--- a/mandrel/test/util/loaders_test.py
+++ b/mandrel/test/util/loaders_test.py
@@ -30,7 +30,7 @@ class TestLoaderFunctions(unittest.TestCase):
         callback = mock.Mock(name='Callback')
         harness = util.harness_loader(loader)(callback)
         name = mock.Mock(name='Plugin')
-        args = [mock.Mock(name='Arg%d' % x) for x in xrange(3)]
+        args = [mock.Mock(name='Arg%d' % x) for x in range(3)]
 
         result = harness(name)
         loader.assert_called_once_with(name)
diff --git a/mandrel/test/util/transforming_list_test.py b/mandrel/test/util/transforming_list_test.py
index 2f6e92c..abe9e38 100644
--- a/mandrel/test/util/transforming_list_test.py
+++ b/mandrel/test/util/transforming_list_test.py
@@ -6,14 +6,14 @@ from mandrel import util
 _trans_count = itertools.count(0)
 
 def mock_transform():
-    t = mock.Mock(name='MockTransform%d' % _trans_count.next())
+    t = mock.Mock(name='MockTransform%d' % next(_trans_count))
     t.side_effect = lambda v: v.transformed
     return t
 
 _vals_count = itertools.count(0)
 
 def mock_value():
-    return mock.Mock(name='MockValue%d' % _vals_count.next())
+    return mock.Mock(name='MockValue%d' % next(_vals_count))
 
 class TestTransformingList(unittest.TestCase):
     def testAppendBasics(self):
@@ -33,7 +33,7 @@ class TestTransformingList(unittest.TestCase):
     def testAccessBasics(self):
         t = mock_transform()
         l = util.TransformingList(t)
-        vals = [mock_value() for i in xrange(5)]
+        vals = [mock_value() for i in range(5)]
         l[0:3] = vals[0:3]
         self.assertEqual(3, len(l))
         self.assertEqual(tuple(v.transformed for v in vals[0:3]), tuple(l))
@@ -51,7 +51,7 @@ class TestTransformingList(unittest.TestCase):
 
     def testExtend(self):
         t = mock_transform()
-        vals = [mock_value() for i in xrange(5)]
+        vals = [mock_value() for i in range(5)]
         exp = tuple(v.transformed for v in vals)
         l = util.TransformingList(t)
         l.extend(vals)
@@ -64,7 +64,7 @@ class TestTransformingList(unittest.TestCase):
 
     def testInsertPop(self):
         t = mock_transform()
-        a, b, c = (mock_value() for i in xrange(3))
+        a, b, c = (mock_value() for i in range(3))
         l = util.TransformingList(t)
         l.append(a)
         l.append(b)
@@ -77,7 +77,7 @@ class TestTransformingList(unittest.TestCase):
 
     def testContainment(self):
         t = mock_transform()
-        vals = [mock_value() for i in xrange(5)]
+        vals = [mock_value() for i in range(5)]
         l = util.TransformingList(t)
         member = lambda v: v in l
         self.assertFalse(member(vals[0]))
diff --git a/mandrel/test/utils.py b/mandrel/test/utils.py
index df7dbc8..3688ce5 100644
--- a/mandrel/test/utils.py
+++ b/mandrel/test/utils.py
@@ -5,6 +5,12 @@ import tempfile
 import mandrel
 import unittest
 
+try:
+    # python 3 compatibility
+    from importlib import reload
+except ImportError:
+    pass
+
 class TestCase(unittest.TestCase):
     def assertIs(self, a, b):
         # python 2.6/2.7 compatibility