diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..edf9bd2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: python +python: + - "2.6" + - "2.7" + - "3.6" +script: +- python setup.py test + diff --git a/README.md b/README.md index 79c1e57..919f9ff 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,15 @@ Mandrel provides bootstrapping and configuration tools for consistent, straightforward project config management. +**Mandrel is a highly idiosyncratic way to organize stuff and is ultimately +counterproductive. The author and maintainer recommends against its continued +use. This project is in maintenance mode only and the author cannot stress +enough the degree to which alternate approaches to bootstrapping should be +found. Don't use mandrel for new work. Deprecate its use where you can. +(FWIW, the author prefers to drive configuration via environment variables, +generally speaking, and only use specialized file-based config with JSON or +YAML if the configuration needs are especially nuanced.)** + Use Mandrel to: * bootstrap your python project configuration diff --git a/changelog.md b/changelog.md index eca8305..99d6506 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,24 @@ +CURRENT + +* Compatibility enhancements to allow code expecting 0.1.0 behavior to work with 0.3.0+ + +0.3.0 + +* Python 3 support + +0.2.0 + +* mandrel-runner uses return value of callable as the exit code. + +0.1.1 + +* Adding travis CI build support for 2.6/2.7 testing + +0.1.0 + +* Added log naming helper functions to mandrel.config.helpers +* Eliminated alpha status and "b" build tag. + 0.0.4b * Fixed bug with option handling in mandrel-runner (thanks to Adam Tebbe) 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 @@ def chained_get(self, attribute): 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..44b3291 100644 --- a/mandrel/runner.py +++ b/mandrel/runner.py @@ -109,15 +109,47 @@ def prepare_environment(self, args): 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) return self.execute_script(target) +def _convert_status(status): + """ + Converts the return value from a callable or script into a status that allows code + expecting mandrel-0.1.0 behavior (which ignored returns) to behave properly + with the latest code the interprets the return as a status code. + + :param status: anything that might have been returned by code + :return: 0 if the code should be thought of as successful, 1 if failed, or the status itself if already an integer + """ + if status is True: + # Exactly True is successful + return 0 + elif status is False: + # Exactly False is failed + return 1 + elif status is None: + # A function completing with no return is considered successful + return 0 + elif isinstance(status, int): + # Any other integer return is deemed a status + return status + else: + # Anything else is treated as successful + return 0 + + def launch_callable(): - return CallableRunner.launch() + return _convert_status(CallableRunner.launch()) + def launch_script(): - return ScriptRunner.launch() + return _convert_status(ScriptRunner.launch()) 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 @@ def testGetLoggerName(self): 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 @@ def testBasicAttributes(self): 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 @@ def testGetConfiguration(self, loader): 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 @@ def testGetPossibleBasenames(self): 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 @@ def testFindConfigurationFiles(self): @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 @@ def testFindConfigurationFilesWithoutMatch(self): @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 func(*a, **kw): 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 @@ def testLogConfigBasenamePath(self, path): @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 @@ def testNotImplemented(self, path, process_options): @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 @@ def testLaunch(self, run): @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 @@ def testScriptRunner(self, mock_exec, mock_globals): 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 @@ def test_harness_loader_straight(self): 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 @@ _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 @@ def testAppendBasics(self): 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 @@ def testAccessBasics(self): 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 @@ def testExtend(self): 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 @@ def testInsertPop(self): 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 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 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 @@ def __init__(self, transformer): 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.cfg b/setup.cfg deleted file mode 100644 index 6218bd6..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[egg_info] -tag_build = dev - diff --git a/setup.py b/setup.py index a679396..0c6b3cc 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name = "mandrel", - version = "0.0.4", + version = "1.0.0", author = "Ethan Rowe", author_email = "ethan@the-rowes.com", description = ("Provides bootstrapping for sane configuration management"), @@ -23,6 +23,15 @@ Mandrel provides bootstrapping and configuration tools for consistent, straightforward project config management. +**Mandrel is a highly idiosyncratic way to organize stuff and is ultimately +counterproductive. The author and maintainer recommends against its continued +use. This project is in maintenance mode only and the author cannot stress +enough the degree to which alternate approaches to bootstrapping should be +found. Don't use mandrel for new work. Deprecate its use where you can. +(FWIW, the author prefers to drive configuration via environment variables, +generally speaking, and only use specialized file-based config with JSON or +YAML if the configuration needs are especially nuanced.)** + Use Mandrel to: * bootstrap your python project configuration @@ -45,7 +54,6 @@ """, test_suite='mandrel.test', classifiers=[ - "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", 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 = +