From 2e3ee148fd9a4f22302fe25644b727fbd31efb94 Mon Sep 17 00:00:00 2001 From: Dolph Mathews Date: Fri, 2 Dec 2011 22:24:13 -0600 Subject: Initial keystone-manage rewrite (bp keystone-manage2) - Example session: http://paste.openstack.org/show/3927/ - Example usage: ./bin/keystone-manage --help - Example usage: ./bin/keystone-manage version --help - Commands are implemented as decorated classes - The name of the command module *is* the name of the command - Testing - Includes base testcase for testing commands, with the following coverage: - Unit test command classes directly - Unit test by passing parsed args (via argparse) - Use output buffer to make assertions on stdout - Includes tests for 'version' command - Includes tests for OutputBuffer - Routing calls through bin/keystone-manage to manage2. - New command names use a different syntax, so none should collide. - Old: 'subject verb' (e.g. 'user add') - New: 'verb_subject' (e.g. 'add_user') - Implication: --help for original keystone-manage is now overridden by that from manage2 - OutputBuffer captures stdout as a string for testing commands - Example usage: with buffout.OutputBuffer() as ob: print 'foobar' assert ob.read() == 'foobar\n' Change-Id: Iafd25d77df600ca70e19925936f3e997109f4d68 --- bin/keystone-manage | 10 ++- keystone/manage2/__init__.py | 66 ++++++++++++++++++++ keystone/manage2/commands/__init__.py | 0 keystone/manage2/commands/version.py | 39 ++++++++++++ keystone/manage2/common.py | 33 ++++++++++ keystone/test/client/test_keystone_manage.py | 2 +- keystone/test/unit/test_buffout.py | 91 ++++++++++++++++++++++++++++ keystone/test/unit/test_commands.py | 64 +++++++++++++++++++ keystone/tools/buffout.py | 83 +++++++++++++++++++++++++ 9 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 keystone/manage2/__init__.py create mode 100644 keystone/manage2/commands/__init__.py create mode 100644 keystone/manage2/commands/version.py create mode 100644 keystone/manage2/common.py create mode 100644 keystone/test/unit/test_buffout.py create mode 100644 keystone/test/unit/test_commands.py create mode 100644 keystone/tools/buffout.py diff --git a/bin/keystone-manage b/bin/keystone-manage index 5bef46f3..4392a0c8 100755 --- a/bin/keystone-manage +++ b/bin/keystone-manage @@ -11,8 +11,16 @@ if os.path.exists(os.path.join(possible_topdir, 'keystone', '__init__.py')): sys.path.insert(0, possible_topdir) import keystone.manage +import keystone.manage2 import keystone.tools.tracer # @UnusedImport # module runs on import if __name__ == '__main__': - keystone.manage.main() + if len(sys.argv) > 1 and sys.argv[1] in keystone.manage.OBJECTS: + # the args look like the old 'subject verb' (e.g. 'user add') + # (this module is pending deprecation) + keystone.manage.main() + else: + # calls that don't start with a 'subject' go to the new impl + # which uses a 'verb_subject' convention (e.g. 'add_user') + keystone.manage2.main() diff --git a/keystone/manage2/__init__.py b/keystone/manage2/__init__.py new file mode 100644 index 00000000..4ed86243 --- /dev/null +++ b/keystone/manage2/__init__.py @@ -0,0 +1,66 @@ +"""OpenStack Identity (Keystone) Management""" + + +import argparse +import pkgutil +import os +import sys + +from keystone.manage2 import commands + + +# builds a complete path to the commands package +PACKAGE_PATH = os.path.dirname(commands.__file__) + +# builds a list of modules in the commands package +MODULES = [tupl for tupl in pkgutil.iter_modules([PACKAGE_PATH])] + + +def load_module(module_name): + """Imports a module given the module name""" + try: + module_loader, name, is_package = [md for md in MODULES + if md[1] == module_name][0] + except IndexError: + raise ValueError("No module found named '%s'" % module_name) + + loader = module_loader.find_module(name) + module = loader.load_module(name) + return module + + +def main(): + # discover command modules + module_names = [name for _, name, _ in MODULES] + module_names.sort() + + # we need the name of the command before hitting argparse + command = None + for pos, arg in enumerate(sys.argv): + if arg in module_names: + command = sys.argv.pop(pos) + break + + if command and command in module_names: + # load, configure and run command + module = load_module(command) + parser = argparse.ArgumentParser(prog=command, + description=module.Command.__doc__) + + # let the command append arguments to the parser + module.Command.append_parser(parser) + args = parser.parse_args() + + # command + exit(module.Command.run(args)) + else: + # show help + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', metavar='command', type=str, + help=', '.join(module_names)) + args = parser.parse_args() + + parser.print_help() + + # always exit 2; something about the input args was invalid + exit(2) diff --git a/keystone/manage2/commands/__init__.py b/keystone/manage2/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keystone/manage2/commands/version.py b/keystone/manage2/commands/version.py new file mode 100644 index 00000000..0610570c --- /dev/null +++ b/keystone/manage2/commands/version.py @@ -0,0 +1,39 @@ +"""Prints keystone's version information""" + + +from keystone import version +from keystone.manage2 import common + + +@common.arg('--api', action='store_true', + default=False, + help='only print the API version') +@common.arg('--implementation', action='store_true', + default=False, + help='only print the implementation version') +class Command(common.BaseCommand): + """Returns keystone version data. + + Includes the latest API version, implementation version, or both, + if neither is specified. + """ + + def get_api_version(self): + """Returns a complete API version string""" + return ' '.join([version.API_VERSION, version.API_VERSION_STATUS]) + + def get_implementation_version(self): + """Returns a complete implementation version string""" + return version.version() + + @staticmethod + def run(args): + """Process argparse args, and print results to stdout""" + cmd = Command() + + show_all = not (args.api or args.implementation) + + if args.api or show_all: + print 'API v%s' % cmd.get_api_version() + if args.implementation or show_all: + print 'Implementation v%s' % cmd.get_implementation_version() diff --git a/keystone/manage2/common.py b/keystone/manage2/common.py new file mode 100644 index 00000000..d9c8dd23 --- /dev/null +++ b/keystone/manage2/common.py @@ -0,0 +1,33 @@ +def arg(name, **kwargs): + """Decorate the command class with an argparse argument""" + def _decorator(cls): + if not hasattr(cls, '_args'): + setattr(cls, '_args', {}) + args = getattr(cls, '_args') + args[name] = kwargs + return cls + return _decorator + + +class BaseCommand(object): + """Provides a common pattern for keystone-manage commands""" + # initialize to an empty dict, in case a command is not decorated + _args = {} + + @staticmethod + def append_parser(parser): + """Appends this command's arguments to an argparser + + :param parser: argparse.ArgumentParser + """ + args = BaseCommand._args + for name in args.keys(): + parser.add_argument(name, **args[name]) + + @staticmethod + def run(args): + """Handles argparse args and prints command results to stdout + + :param args: argparse Namespace + """ + raise NotImplemented() diff --git a/keystone/test/client/test_keystone_manage.py b/keystone/test/client/test_keystone_manage.py index bb0e0a2e..dc5cc677 100644 --- a/keystone/test/client/test_keystone_manage.py +++ b/keystone/test/client/test_keystone_manage.py @@ -30,7 +30,7 @@ class TestKeystoneManage(unittest.TestCase): ] process = subprocess.Popen(cmd, stdout=subprocess.PIPE) result = process.communicate()[0] - self.assertIn('Usage', result) + self.assertIn('usage', result) def test_keystone_manage_calls(self): """ diff --git a/keystone/test/unit/test_buffout.py b/keystone/test/unit/test_buffout.py new file mode 100644 index 00000000..715960e0 --- /dev/null +++ b/keystone/test/unit/test_buffout.py @@ -0,0 +1,91 @@ +import unittest2 as unittest +import sys + +from keystone.tools import buffout + + +class TestStdoutIdentity(unittest.TestCase): + """Tests buffout's manipulation of the stdout pointer""" + def test_stdout(self): + stdout = sys.stdout + ob = buffout.OutputBuffer() + self.assertTrue(sys.stdout is stdout, + "sys.stdout was replaced") + ob.start() + self.assertTrue(sys.stdout is not stdout, + "sys.stdout not replaced") + ob.stop() + self.assertTrue(sys.stdout is stdout, + "sys.stdout not restored") + + +class TestOutputBufferContents(unittest.TestCase): + """Tests the contents of the buffer""" + def test_read_contents(self): + with buffout.OutputBuffer() as ob: + print 'foobar' + print 'wompwomp' + output = ob.read() + self.assertEquals(len(output), 16, output) + self.assertIn('foobar', output) + self.assertIn('ompwom', output) + + def test_read_lines(self): + with buffout.OutputBuffer() as ob: + print 'foobar' + print 'wompwomp' + lines = ob.read_lines() + self.assertTrue(isinstance(lines, list)) + self.assertEqual(len(lines), 2) + self.assertIn('foobar', lines) + self.assertIn('wompwomp', lines) + + def test_additional_output(self): + with buffout.OutputBuffer() as ob: + print 'foobar' + lines = ob.read_lines() + self.assertEqual(len(lines), 1) + print 'wompwomp' + lines = ob.read_lines() + self.assertEqual(len(lines), 2) + + def test_clear(self): + with buffout.OutputBuffer() as ob: + print 'foobar' + ob.clear() + print 'wompwomp' + output = ob.read() + self.assertNotIn('foobar', output) + self.assertIn('ompwom', output) + + def test_buffer_preservation(self): + ob = buffout.OutputBuffer() + ob.start() + + print 'foobar' + print 'wompwomp' + + ob.stop() + + output = ob.read() + self.assertIn('foobar', output) + self.assertIn('ompwom', output) + + def test_buffer_contents(self): + ob = buffout.OutputBuffer() + ob.start() + + print 'foobar' + print 'wompwomp' + + ob.stop() + + self.assertEqual('foobar\nwompwomp\n', unicode(ob)) + self.assertEqual('foobar\nwompwomp\n', str(ob)) + + def test_exception_raising(self): + def raise_value_error(): + with buffout.OutputBuffer(): + raise ValueError() + + self.assertRaises(ValueError, raise_value_error) diff --git a/keystone/test/unit/test_commands.py b/keystone/test/unit/test_commands.py new file mode 100644 index 00000000..3125ba1f --- /dev/null +++ b/keystone/test/unit/test_commands.py @@ -0,0 +1,64 @@ +import argparse +import logging +import unittest2 as unittest + +from keystone.manage2.commands import version +from keystone.tools import buffout + + +LOGGER = logging.getLogger(__name__) + + +class CommandTestCase(unittest.TestCase): + """Buffers stdout to test keystone-manage commands""" + module = None + stdout = None + + def setUp(self): + # initialize the command module + self.cmd = self.module.Command() + + # create an argparser for the module + self.parser = argparse.ArgumentParser() + version.Command.append_parser(self.parser) + + +class TestVersionCommand(CommandTestCase): + """Tests for ./bin/keystone-manage version""" + module = version + + API_VERSION = '2.0 beta' + IMPLEMENTATION_VERSION = '2012.1-dev' + + def test_api_version(self): + v = self.cmd.get_api_version() + self.assertEqual(v, self.API_VERSION) + + def test_implementation_version(self): + v = self.cmd.get_implementation_version() + self.assertEqual(v, self.IMPLEMENTATION_VERSION) + + def test_no_args(self): + with buffout.OutputBuffer() as ob: + args = self.parser.parse_args([]) + self.cmd.run(args) + lines = ob.read_lines() + self.assertEqual(len(lines), 2, lines) + self.assertIn(self.API_VERSION, lines[0]) + self.assertIn(self.IMPLEMENTATION_VERSION, lines[1]) + + def test_api_arg(self): + with buffout.OutputBuffer() as ob: + args = self.parser.parse_args('--api'.split()) + self.cmd.run(args) + lines = ob.read_lines() + self.assertEqual(len(lines), 1, lines) + self.assertIn(self.API_VERSION, lines[0]) + + def test_implementation_arg(self): + with buffout.OutputBuffer() as ob: + args = self.parser.parse_args('--implementation'.split()) + self.cmd.run(args) + lines = ob.read_lines() + self.assertEqual(len(lines), 1, lines) + self.assertIn(self.IMPLEMENTATION_VERSION, lines[0]) diff --git a/keystone/tools/buffout.py b/keystone/tools/buffout.py new file mode 100644 index 00000000..e0202c0e --- /dev/null +++ b/keystone/tools/buffout.py @@ -0,0 +1,83 @@ +import sys +import StringIO + + +class OutputBuffer(): + """Replaces stdout with a StringIO buffer""" + + def __init__(self): + """Initialize output buffering""" + # True if the OutputBuffer is started + self.buffering = False + + # a reference to the current StringIO buffer + self._buffer = None + + # stale if buffering is True; access buffer contents using .read() + self._contents = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is None: + self.stop() + else: + raise + + def __unicode__(self): + return self._contents + + def __str__(self): + return str(self._contents) + + def start(self): + """Replace stdout with a fresh buffer""" + assert not self.buffering + + self.buffering = True + self.old_stdout = sys.stdout + + self.clear() + + def read(self): + """Read the current buffer""" + if self.buffering: + self._contents = self._buffer.getvalue() + + return self._contents + + def read_lines(self): + """Returns the current buffer as a list + + Excludes the last line, which is empty. + + """ + return self.read().split("\n")[:-1] + + def clear(self): + """Resets the current buffer""" + assert self.buffering + + # dispose of the previous buffer, if any + if self._buffer is not None: + self._buffer.close() + + self._contents = '' + self._buffer = StringIO.StringIO() + sys.stdout = self._buffer + + def stop(self): + """Stop buffering and pass the output along""" + assert self.buffering + + # preserve the contents prior to closing the StringIO + self.read() + self._buffer.close() + + sys.stdout = self.old_stdout + print self + self.buffering = False + + return unicode(self) -- cgit