Implement a rough CLI foundation

This is a foundation for CLI work, still has rough spots but works for
two PoC commands - rack-list and rack-show (communicates with Tuskar,
prints). There's remaining stuff to be solved:

* Allow arbitrary formatting of attributes (bug #1213056)

* Add help support for subcommands (bug #1213050)

* Allow finding resources by name (bug #1213053)

* Allow auth with pre-existing token and Keystone URL (bug #1213052)

Fixes bug #1211826

Change-Id: I9364be37c7111c85ef46be82b157782a14743004
This commit is contained in:
Jiri Stransky 2013-08-15 15:39:19 +02:00
parent ac1875bd52
commit add9e5da58
10 changed files with 300 additions and 130 deletions

View File

@ -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):

View File

@ -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='<subcommand>')
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:")

View File

@ -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='<NUMBER>', 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='<NUMBER>', 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

View File

@ -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)

View File

@ -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

View File

@ -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', '')

View File

@ -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.

View File

@ -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="<NAME or ID>", 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)

View File

@ -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.

35
tuskarclient/v1/shell.py Normal file
View File

@ -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)