diff --git a/tuskarclient/common/utils.py b/tuskarclient/common/utils.py index e5ecb9e..3c4525b 100644 --- a/tuskarclient/common/utils.py +++ b/tuskarclient/common/utils.py @@ -24,6 +24,34 @@ from tuskarclient import exc from tuskarclient.openstack.common import importutils +def define_commands_from_module(subparsers, command_module): + '''Find all methods beginning with 'do_' in a module, and add them + as commands into a subparsers collection. + ''' + for method_name in (a for a in dir(command_module) if a.startswith('do_')): + # Commands should be hypen-separated instead of underscores. + command = method_name[3:].replace('_', '-') + callback = getattr(command_module, method_name) + define_command(subparsers, command, callback) + + +def define_command(subparsers, command, callback): + '''Define a command in the subparsers collection. + + :param subparsers: subparsers collection where the command will go + :param command: command name + :param callback: function that will be used to process the command + ''' + desc = callback.__doc__ or '' + help = desc.strip().split('\n')[0] + arguments = getattr(callback, 'arguments', []) + + subparser = subparsers.add_parser(command, help=help, description=desc) + for (args, kwargs) in arguments: + subparser.add_argument(*args, **kwargs) + subparser.set_defaults(func=callback) + + # Decorator for cli-args def arg(*args, **kwargs): def _decorator(func): diff --git a/tuskarclient/shell.py b/tuskarclient/shell.py index b5368d2..f524218 100755 --- a/tuskarclient/shell.py +++ b/tuskarclient/shell.py @@ -16,10 +16,14 @@ Command-line interface to the Heat API. from __future__ import print_function +import argparse import logging import logging.handlers import sys -import tuskarclient.v1.argparsers + +from tuskarclient import client +import tuskarclient.common.utils as utils +from tuskarclient import exc logger = logging.getLogger(__name__) @@ -30,15 +34,23 @@ class TuskarShell(object): self.raw_args = raw_args def run(self): - parser = tuskarclient.v1.argparsers.create_top_parser() - args = parser.parse_args(self.raw_args) + '''Run the CLI. Parse arguments and do the respective action.''' - if args.help or not self.raw_args: + nonversioned_parser = self._nonversioned_parser() + partial_args = nonversioned_parser.parse_known_args(self.raw_args)[0] + parser = self._parser(partial_args.tuskar_api_version) + + if partial_args.help or not self.raw_args: parser.print_help() return 0 + args = parser.parse_args(self.raw_args) self._ensure_auth_info(args) + tuskar_client = client.get_client(partial_args.tuskar_api_version, + **args.__dict__) + args.func(tuskar_client, args) + def _ensure_auth_info(self, args): '''Ensure that authentication information is provided. Two variants of authentication are supported: @@ -47,37 +59,133 @@ class TuskarShell(object): ''' if not args.os_auth_token: if not args.os_username: - raise UsageError("You must provide username via either " - "--os-username or env[OS_USERNAME]") + raise exc.CommandError("You must provide username via either " + "--os-username or env[OS_USERNAME]") if not args.os_password: - raise UsageError("You must provide password via either " - "--os-password or env[OS_PASSWORD]") + raise exc.CommandError("You must provide password via either " + "--os-password or env[OS_PASSWORD]") if not args.os_tenant_id and not args.os_tenant_name: - raise UsageError("You must provide tenant via either " - "--os-tenant-name or --os-tenant-id or " - "env[OS_TENANT_NAME] or env[OS_TENANT_ID]") + raise exc.CommandError("You must provide tenant via either " + "--os-tenant-name or --os-tenant-id or " + "env[OS_TENANT_NAME] or " + "env[OS_TENANT_ID]") if not args.os_auth_url: - raise UsageError("You must provide auth URL via either " - "--os-auth-url or env[OS_AUTH_URL]") + raise exc.CommandError("You must provide auth URL via either " + "--os-auth-url or env[OS_AUTH_URL]") else: if not args.tuskar_url and not args.os_auth_url: - raise UsageError("You must provide either " - "--tuskar_url or --os_auth_url or " - "env[TUSKAR_URL] or env[OS_AUTH_URL]") + raise exc.CommandError("You must provide either " + "--tuskar-url or --os-auth-url or " + "env[TUSKAR_URL] or env[OS_AUTH_URL]") + def _parser(self, version): + '''Create a top level argument parser. -class UsageError(Exception): - pass + :param version: version of Tuskar API (and corresponding CLI + commands) to use + ''' + parser = self._nonversioned_parser() + subparsers = parser.add_subparsers(metavar='') + versioned_shell = utils.import_versioned_module(version, 'shell') + versioned_shell.enhance_parser(parser, subparsers) + return parser + + def _nonversioned_parser(self): + '''Create a basic parser that doesn't contain version-specific + subcommands. This one is mainly useful for parsing which API + version should be used for the versioned full blown parser and + defining common version-agnostic options. + ''' + parser = argparse.ArgumentParser( + prog='tuskar', + description='OpenStack Management CLI', + add_help=False + ) + + parser.add_argument('-h', '--help', + action='store_true', + help="Print this help message and exit.", + ) + + parser.add_argument('--os-username', + default=utils.env('OS_USERNAME'), + help='Defaults to env[OS_USERNAME]', + ) + + parser.add_argument('--os_username', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--os-password', + default=utils.env('OS_PASSWORD'), + help='Defaults to env[OS_PASSWORD]', + ) + + parser.add_argument('--os_password', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--os-tenant-id', + default=utils.env('OS_TENANT_ID'), + help='Defaults to env[OS_TENANT_ID]', + ) + + parser.add_argument('--os_tenant_id', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--os-tenant-name', + default=utils.env('OS_TENANT_NAME'), + help='Defaults to env[OS_TENANT_NAME]', + ) + + parser.add_argument('--os_tenant_name', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--os-auth-url', + default=utils.env('OS_AUTH_URL'), + help='Defaults to env[OS_AUTH_URL]', + ) + + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS, + ) + + parser.add_argument('--os-auth-token', + default=utils.env('OS_AUTH_TOKEN'), + help='Defaults to env[OS_AUTH_TOKEN]') + + parser.add_argument('--os_auth_token', + help=argparse.SUPPRESS) + + parser.add_argument('--tuskar-url', + default=utils.env('TUSKAR_URL'), + help='Defaults to env[TUSKAR_URL]') + + parser.add_argument('--tuskar_url', + help=argparse.SUPPRESS) + + parser.add_argument('--tuskar-api-version', + default=utils.env('TUSKAR_API_VERSION', + default='1'), + help='Defaults to env[TUSKAR_API_VERSION] ' + 'or 1') + + parser.add_argument('--tuskar_api_version', + help=argparse.SUPPRESS) + + return parser def main(): logger.addHandler(logging.StreamHandler(sys.stderr)) try: TuskarShell(sys.argv[1:]).run() - except UsageError as e: + except exc.CommandError as e: print(e.message, file=sys.stderr) except Exception as e: logger.exception("Exiting due to an error:") diff --git a/tuskarclient/tests/test_utils.py b/tuskarclient/tests/common/test_utils.py similarity index 57% rename from tuskarclient/tests/test_utils.py rename to tuskarclient/tests/common/test_utils.py index 5bccb6c..759bff8 100644 --- a/tuskarclient/tests/test_utils.py +++ b/tuskarclient/tests/common/test_utils.py @@ -15,6 +15,7 @@ import cStringIO +import mock import sys from tuskarclient.common import utils @@ -45,3 +46,31 @@ class UtilsTest(test_utils.TestCase): | Key | Value | +----------+-------+ ''') + + +class DefineCommandsTest(test_utils.TestCase): + + def test_define_commands_from_module(self): + subparsers = mock.Mock() + subparser = mock.MagicMock() + subparsers.add_parser.return_value = subparser + dummy_module = self.dummy_command_module() + + utils.define_commands_from_module(subparsers, dummy_module) + subparsers.add_parser.assert_called_with( + 'dummy-list', help="Docstring.", description="Docstring.") + subparser.add_argument.assert_called_with( + '-a', metavar='', help="Add a number.") + subparser.set_defaults.assert_called_with( + func=dummy_module.do_dummy_list) + + def dummy_command_module(self): + @utils.arg('-a', metavar='', help="Add a number.") + def do_dummy_list(): + '''Docstring.''' + return 42 + + dummy = mock.Mock() + dummy.do_dummy_list = do_dummy_list + dummy.other_method = mock.Mock('other_method', return_value=43) + return dummy diff --git a/tuskarclient/tests/test_shell.py b/tuskarclient/tests/test_shell.py index 18fdbac..39c4a8e 100644 --- a/tuskarclient/tests/test_shell.py +++ b/tuskarclient/tests/test_shell.py @@ -10,47 +10,62 @@ # License for the specific language governing permissions and limitations # under the License. +from tuskarclient import exc from tuskarclient import shell import tuskarclient.tests.utils as tutils class ShellTest(tutils.TestCase): + args_attributes = [ + 'os_username', 'os_password', 'os_tenant_name', 'os_tenant_id', + 'os_auth_url', 'os_auth_token', 'tuskar_url', 'tuskar_api_version', + ] + def setUp(self): super(ShellTest, self).setUp() self.s = shell.TuskarShell({}) def empty_args(self): args = lambda: None # i'd use object(), but it can't have attributes - args_attributes = [ - 'os_username', 'os_password', 'os_tenant_name', 'os_tenant_id', - 'os_auth_url', 'os_auth_token', 'tuskar_url', - ] - for attr in args_attributes: + for attr in self.args_attributes: setattr(args, attr, None) return args def test_ensure_auth_info_with_credentials(self): ensure = self.s._ensure_auth_info - usage_error = shell.UsageError + command_error = exc.CommandError args = self.empty_args() args.os_username = 'user' args.os_password = 'pass' args.os_tenant_name = 'tenant' - self.assertRaises(usage_error, ensure, args) + self.assertRaises(command_error, ensure, args) args.os_auth_url = 'keystone' ensure(args) # doesn't raise def test_ensure_auth_info_with_token(self): ensure = self.s._ensure_auth_info - usage_error = shell.UsageError + command_error = exc.CommandError args = self.empty_args() args.os_auth_token = 'token' - self.assertRaises(usage_error, ensure, args) + self.assertRaises(command_error, ensure, args) args.tuskar_url = 'tuskar' ensure(args) # doesn't raise + + def test_parser_v1(self): + v1_commands = [ + 'rack-list', 'rack-show', + ] + parser = self.s._parser(1) + tuskar_help = parser.format_help() + + for arg in map(lambda a: a.replace('_', '-'), self.args_attributes): + self.assertIn(arg, tuskar_help) + + for command in v1_commands: + self.assertIn(command, tuskar_help) diff --git a/tuskarclient/v1/argparsers.py b/tuskarclient/v1/argparsers.py deleted file mode 100644 index 4d3613c..0000000 --- a/tuskarclient/v1/argparsers.py +++ /dev/null @@ -1,87 +0,0 @@ -# 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 argparse -import tuskarclient.utils as utils - - -def create_top_parser(): - parser = argparse.ArgumentParser(prog='tuskar', - description='OpenStack Management CLI', - add_help=False - ) - - parser.add_argument('-h', '--help', - action='store_true', - help="Print this help message and exit.", - ) - - parser.add_argument('--os-username', - default=utils.env('OS_USERNAME'), - help='Defaults to env[OS_USERNAME]', - ) - - parser.add_argument('--os_username', - help=argparse.SUPPRESS, - ) - - parser.add_argument('--os-password', - default=utils.env('OS_PASSWORD'), - help='Defaults to env[OS_PASSWORD]', - ) - - parser.add_argument('--os_password', - help=argparse.SUPPRESS, - ) - - parser.add_argument('--os-tenant-id', - default=utils.env('OS_TENANT_ID'), - help='Defaults to env[OS_TENANT_ID]', - ) - - parser.add_argument('--os_tenant_id', - help=argparse.SUPPRESS, - ) - - parser.add_argument('--os-tenant-name', - default=utils.env('OS_TENANT_NAME'), - help='Defaults to env[OS_TENANT_NAME]', - ) - - parser.add_argument('--os_tenant_name', - help=argparse.SUPPRESS, - ) - - parser.add_argument('--os-auth-url', - default=utils.env('OS_AUTH_URL'), - help='Defaults to env[OS_AUTH_URL]', - ) - - parser.add_argument('--os_auth_url', - help=argparse.SUPPRESS, - ) - - parser.add_argument('--os-auth-token', - default=utils.env('OS_AUTH_TOKEN'), - help='Defaults to env[OS_AUTH_TOKEN]') - - parser.add_argument('--os_auth_token', - help=argparse.SUPPRESS) - - parser.add_argument('--tuskar-url', - default=utils.env('TUSKAR_URL'), - help='Defaults to env[TUSKAR_URL]') - - parser.add_argument('--tuskar_url', - help=argparse.SUPPRESS) - - return parser diff --git a/tuskarclient/utils.py b/tuskarclient/v1/data_centers_shell.py similarity index 61% rename from tuskarclient/utils.py rename to tuskarclient/v1/data_centers_shell.py index d0691ec..94e731d 100644 --- a/tuskarclient/utils.py +++ b/tuskarclient/v1/data_centers_shell.py @@ -9,18 +9,3 @@ # 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 - - -def env(*vars, **kwargs): - """Search for the first defined of possibly many env vars - - Returns the first environment variable defined in vars, or - returns the default defined in kwargs. - """ - for v in vars: - value = os.environ.get(v, None) - if value: - return value - return kwargs.get('default', '') diff --git a/tuskarclient/v1/flavors_shell.py b/tuskarclient/v1/flavors_shell.py new file mode 100644 index 0000000..94e731d --- /dev/null +++ b/tuskarclient/v1/flavors_shell.py @@ -0,0 +1,11 @@ +# 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. diff --git a/tuskarclient/v1/racks_shell.py b/tuskarclient/v1/racks_shell.py new file mode 100644 index 0000000..da008c4 --- /dev/null +++ b/tuskarclient/v1/racks_shell.py @@ -0,0 +1,35 @@ +# 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 tuskarclient.common import utils +from tuskarclient import exc + + +# TODO(jistr): This is PoC, not final implementation +@utils.arg('id', metavar="", help="Name or ID of rack to show.") +def do_rack_show(tuskar, args): + try: + rack = utils.find_resource(tuskar.racks, args.id) + except exc.HTTPNotFound: + raise exc.CommandError("Rack not found: %s" % args.id) + + utils.print_dict(rack.to_dict()) + + +# TODO(jistr): This is PoC, not final implementation +def do_rack_list(tuskar, args): + racks = tuskar.racks.list() + fields = ['name', 'subnet', 'state', 'nodes'] + labels = ["Name", "Subnet", "State", "Nodes"] + formatters = {'nodes': lambda rack: len(rack.nodes)} + + utils.print_list(racks, fields, labels, formatters, 0) diff --git a/tuskarclient/v1/resource_classes_shell.py b/tuskarclient/v1/resource_classes_shell.py new file mode 100644 index 0000000..94e731d --- /dev/null +++ b/tuskarclient/v1/resource_classes_shell.py @@ -0,0 +1,11 @@ +# 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. diff --git a/tuskarclient/v1/shell.py b/tuskarclient/v1/shell.py new file mode 100644 index 0000000..45106b8 --- /dev/null +++ b/tuskarclient/v1/shell.py @@ -0,0 +1,35 @@ +# 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 tuskarclient.common import utils +from tuskarclient.v1 import data_centers_shell +from tuskarclient.v1 import flavors_shell +from tuskarclient.v1 import racks_shell +from tuskarclient.v1 import resource_classes_shell + +COMMAND_MODULES = [ + data_centers_shell, + flavors_shell, + racks_shell, + resource_classes_shell, +] + + +def enhance_parser(parser, subparsers): + '''Take a basic (nonversioned) parser and enhance it with + commands and options specific for this version of API. + + :param parser: top level parser :param subparsers: top level + parser's subparsers collection where subcommands will go + ''' + for command_module in COMMAND_MODULES: + utils.define_commands_from_module(subparsers, command_module)