From b74a34956c8c2e47a29dc6ec16cd5b2fd9f50c8f Mon Sep 17 00:00:00 2001 From: aviau Date: Fri, 19 Dec 2014 17:24:41 -0500 Subject: [PATCH] Added CLI interface Change-Id: I97fed7332ed4985d17bce3c21cef0ed504b62b88 --- .gitignore | 7 + .testr.conf | 9 + requirements.txt | 3 + setup.cfg | 4 + surveilclient/client.py | 22 +++ surveilclient/common/utils.py | 74 +++++++++ surveilclient/exc.py | 17 ++ surveilclient/openstack/__init__.py | 0 surveilclient/openstack/common/__init__.py | 0 surveilclient/openstack/common/importutils.py | 67 ++++++++ surveilclient/shell.py | 156 ++++++++++++++++++ surveilclient/tests/__init__.py | 0 surveilclient/tests/test_shell.py | 43 +++++ surveilclient/v1_0/shell.py | 34 ++++ test-requirements.txt | 2 + tox.ini | 6 +- 16 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 .testr.conf create mode 100644 surveilclient/client.py create mode 100644 surveilclient/common/utils.py create mode 100644 surveilclient/exc.py create mode 100644 surveilclient/openstack/__init__.py create mode 100644 surveilclient/openstack/common/__init__.py create mode 100644 surveilclient/openstack/common/importutils.py create mode 100644 surveilclient/shell.py create mode 100644 surveilclient/tests/__init__.py create mode 100644 surveilclient/tests/test_shell.py create mode 100644 surveilclient/v1_0/shell.py diff --git a/.gitignore b/.gitignore index 3cf10d2..8f6f9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ # documentation doc/build doc/source/_build + +.testrepository +dist +env +.tox +*.egg-info +*.egg diff --git a/.testr.conf b/.testr.conf new file mode 100644 index 0000000..9e3d42b --- /dev/null +++ b/.testr.conf @@ -0,0 +1,9 @@ +[DEFAULT] +test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ + OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \ + OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-160} \ + ${PYTHON:-python} -m subunit.run discover -t ./ ./surveilclient/tests $LISTOPT $IDOPTION + +test_id_option=--load-list $IDFILE +test_list_option=--list + diff --git a/requirements.txt b/requirements.txt index e69de29..223b919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,3 @@ +oslo.serialization +prettytable +pbr==0.10.4 diff --git a/setup.cfg b/setup.cfg index cb0d93a..9678849 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,10 @@ description-file = packages = surveilclient +[entry_points] +console_scripts = + surveil = surveilclient.shell:main + [build_sphinx] source-dir = doc/source build-dir = doc/build diff --git a/surveilclient/client.py b/surveilclient/client.py new file mode 100644 index 0000000..15406af --- /dev/null +++ b/surveilclient/client.py @@ -0,0 +1,22 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from surveilclient.common import utils + + +def Client(version, *args, **kwargs): + module = utils.import_versioned_module(version, 'client') + client_class = getattr(module, 'Client') + return client_class(*args, **kwargs) diff --git a/surveilclient/common/utils.py b/surveilclient/common/utils.py new file mode 100644 index 0000000..486745b --- /dev/null +++ b/surveilclient/common/utils.py @@ -0,0 +1,74 @@ +# Copyright (c) 2013 OpenStack Foundation +# Copyright 2014 - Savoir-Faire Linux inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import prettytable + +from surveilclient.openstack.common import importutils +from oslo.serialization import jsonutils + + +# Decorator for cli-args +def arg(*args, **kwargs): + def _decorator(func): + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.__dict__.setdefault('arguments', []).insert(0, (args, kwargs)) + return func + return _decorator + + +def env(*vars, **kwargs): + """ + returns the first environment variable set + if none are non-empty, defaults to '' or keyword arg default + """ + for v in vars: + value = os.environ.get(v, None) + if value: + return value + return kwargs.get('default', '') + + +def import_versioned_module(version, submodule=None): + module = 'surveilclient.v%s' % version + if submodule: + module = '.'.join((module, submodule)) + return importutils.import_module(module) + + +def json_formatter(js): + return jsonutils.dumps(js, indent=2, ensure_ascii=False) + + +def print_list(objs, fields, field_labels=None, formatters={}, sortby=None): + field_labels = field_labels or fields + pt = prettytable.PrettyTable([f for f in field_labels], + caching=False, print_empty=False) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + data = getattr(o, field, None) or '' + row.append(data) + pt.add_row(row) + if sortby is None: + print(pt.get_string()) + else: + print(pt.get_string(sortby=field_labels[sortby])) diff --git a/surveilclient/exc.py b/surveilclient/exc.py new file mode 100644 index 0000000..3dd78d6 --- /dev/null +++ b/surveilclient/exc.py @@ -0,0 +1,17 @@ +# Copyright 2014 - Savoir-Faire Linux inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class CommandError(BaseException): + """Invalid usage of CLI.""" diff --git a/surveilclient/openstack/__init__.py b/surveilclient/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/surveilclient/openstack/common/__init__.py b/surveilclient/openstack/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/surveilclient/openstack/common/importutils.py b/surveilclient/openstack/common/importutils.py new file mode 100644 index 0000000..e6bf556 --- /dev/null +++ b/surveilclient/openstack/common/importutils.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Import related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError): + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """ + Import a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] + + +def try_import(import_str, default=None): + """Try to import a module and if it fails return default.""" + try: + return import_module(import_str) + except ImportError: + return default diff --git a/surveilclient/shell.py b/surveilclient/shell.py new file mode 100644 index 0000000..d0bc765 --- /dev/null +++ b/surveilclient/shell.py @@ -0,0 +1,156 @@ +# Copyright 2014 - Savoir-Faire Linux inc. +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Command-line interface to the surveil API. +""" + +from __future__ import print_function + +from surveilclient import client as surveil_client +from surveilclient.common import utils +from surveilclient import exc + +import argparse +import sys + + +class SurveilShell(object): + + def get_base_parser(self): + parser = argparse.ArgumentParser( + prog='surveil', + description=__doc__.strip(), + epilog='See "surveil help COMMAND" ' + 'for help on a specific command.', + add_help=False, + formatter_class=HelpFormatter, + ) + + parser.add_argument('--surveil-api-url', + default=utils.env('SURVEIL_API_URL'), + help='Defaults to env[SURVEIL_API_URL].') + + parser.add_argument('--surveil-api-version', + default=utils.env( + 'SURVEIL_API_VERSION', + default='1_0'), + help='Defaults to env[SURVEIL_API_VERSION] or 1_0') + + parser.add_argument('-j', '--json', + action='store_true', + help='output raw json response') + + return parser + + def get_subcommand_parser(self, version): + parser = self.get_base_parser() + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') + submodule = utils.import_versioned_module(version, 'shell') + self._find_actions(subparsers, submodule) + self._find_actions(subparsers, self) + return parser + + def _find_actions(self, subparsers, actions_module): + for attr in (a for a in dir(actions_module) if a.startswith('do_')): + # I prefer to be hyphen-separated instead of underscores. + command = attr[3:].replace('_', '-') + callback = getattr(actions_module, attr) + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, + help=help, + description=desc, + add_help=False, + formatter_class=HelpFormatter) + subparser.add_argument('-h', '--help', + action='help', + help=argparse.SUPPRESS) + self.subcommands[command] = subparser + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + @utils.arg('command', metavar='', nargs='?', + help='Display help for .') + def do_help(self, args): + """Display help about this program or one of its subcommands.""" + if getattr(args, 'command', None): + if args.command in self.subcommands: + self.subcommands[args.command].print_help() + else: + raise exc.CommandError("'%s' is not a valid subcommand" % + args.command) + else: + self.parser.print_help() + + def main(self, argv): + # Parse args once to find version + parser = self.get_base_parser() + (options, args) = parser.parse_known_args(argv) + + # build available subcommands based on version + api_version = options.surveil_api_version + subcommand_parser = self.get_subcommand_parser(api_version) + self.parser = subcommand_parser + + # Handle top-level --help/-h before attempting to parse + # a command off the command line + if not args and options.help or not argv: + self.do_help(options) + return 0 + + # Parse args again and call whatever callback was selected + args = subcommand_parser.parse_args(argv) + + # Short-circuit and deal with help command right away. + if args.func == self.do_help: + self.do_help(args) + return 0 + + if not args.surveil_api_url: + raise exc.CommandError("you must specify a Surveil API URL" + " via either --surveil-api-url or" + " env[SURVEIL_API_URL]") + + endpoint = args.surveil_api_url + + client = surveil_client.Client(api_version, endpoint) + + args.func(client, args) + + +class HelpFormatter(argparse.HelpFormatter): + def start_section(self, heading): + # Title-case the headings + heading = '%s%s' % (heading[0].upper(), heading[1:]) + super(HelpFormatter, self).start_section(heading) + + +def main(): + try: + SurveilShell().main(sys.argv[1:]) + except KeyboardInterrupt: + print("... terminating surveil client", file=sys.stderr) + sys.exit(130) + except Exception as e: + print(str(e), file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/surveilclient/tests/__init__.py b/surveilclient/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/surveilclient/tests/test_shell.py b/surveilclient/tests/test_shell.py new file mode 100644 index 0000000..a0099d7 --- /dev/null +++ b/surveilclient/tests/test_shell.py @@ -0,0 +1,43 @@ +# Copyright 2014 - Savoir-Faire Linux inc. +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import sys +import unittest + +import six + +from surveilclient import exc +from surveilclient import shell as surveil_shell + + +class ShellTest(unittest.TestCase): + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = six.StringIO() + _shell = surveil_shell.SurveilShell() + _shell.main(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(0, exc_value.code) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + return out + + def test_help_unknown_command(self): + self.assertRaises(exc.CommandError, self.shell, 'help foofoo') diff --git a/surveilclient/v1_0/shell.py b/surveilclient/v1_0/shell.py new file mode 100644 index 0000000..7fba620 --- /dev/null +++ b/surveilclient/v1_0/shell.py @@ -0,0 +1,34 @@ +# Copyright 2014 - Savoir-Faire Linux inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from surveilclient.common import utils + + +def do_host_list(sc, args): + """List all hosts.""" + hosts = sc.hosts.list() + + if args.json: + print(utils.json_formatter(hosts)) + else: + cols = [ + 'host_name', + 'address', + ] + + formatters = { + 'host_name': lambda x: x['host_name'], + 'address': lambda x: x['address'], + } + utils.print_list(hosts, cols, formatters=formatters) diff --git a/test-requirements.txt b/test-requirements.txt index 96091ea..33657f6 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,3 +2,5 @@ hacking>=0.9.2,<0.10 sphinx oslosphinx +six +testrepository diff --git a/tox.ini b/tox.ini index f7acc23..5ac8358 100644 --- a/tox.ini +++ b/tox.ini @@ -8,12 +8,16 @@ usedevelop = True install_command = pip install -U --force-reinstall {opts} {packages} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt +commands = python setup.py testr --slowest --testr-args='{posargs}' [testenv:pep8] commands = flake8 +[testenv:venv] +commands = {posargs} + [testenv:docs] commands = python setup.py build_sphinx [flake8] -exclude = .venv,.git,.tox,env,dist,*openstack/common*,*lib/python*/,*egg,build +exclude = .venv,.git,.tox,env,dist,*openstack/common*,surveilclient/common/utils.py,*lib/python*/,*egg,build