Commit ac7346a4 authored by Stavros Sachtouris's avatar Stavros Sachtouris
Browse files

Merge branch 'feature-test-command-tree' into develop

parents 81b0838d a7aacf12
......@@ -340,19 +340,19 @@ def _groups_help(arguments):
global _debug
global kloger
descriptions = {}
acceptable_groups = arguments['config'].get_groups()
for cmd_group, spec in arguments['config'].get_cli_specs():
acceptable_groups = arguments['config'].groups
for cmd_group, spec in arguments['config'].cli_specs:
pkg = _load_spec_module(spec, arguments, '_commands')
if pkg:
cmds = getattr(pkg, '_commands')
try:
for cmd in cmds:
if cmd.name in acceptable_groups:
descriptions[cmd.name] = cmd.description
for cmd_tree in cmds:
if cmd_tree.name in acceptable_groups:
descriptions[cmd_tree.name] = cmd_tree.description
except TypeError:
if _debug:
kloger.warning(
'No cmd description for module %s' % cmd_group)
'No cmd description (help) for module %s' % cmd_group)
elif _debug:
kloger.warning('Loading of %s cmd spec failed' % cmd_group)
print('\nOptions:\n - - - -')
......@@ -361,7 +361,7 @@ def _groups_help(arguments):
def _load_all_commands(cmd_tree, arguments):
_cnf = arguments['config']
for cmd_group, spec in _cnf.get_cli_specs():
for cmd_group, spec in _cnf.cli_specs:
try:
spec_module = _load_spec_module(spec, arguments, '_commands')
spec_commands = getattr(spec_module, '_commands')
......@@ -381,9 +381,9 @@ def _load_all_commands(cmd_tree, arguments):
def print_subcommands_help(cmd):
printout = {}
for subcmd in cmd.get_subcommands():
for subcmd in cmd.subcommands.values():
spec, sep, print_path = subcmd.path.partition('_')
printout[print_path.replace('_', ' ')] = subcmd.description
printout[print_path.replace('_', ' ')] = subcmd.help
if printout:
print('\nOptions:\n - - - -')
print_dict(printout)
......@@ -396,18 +396,14 @@ def update_parser_help(parser, cmd):
description = ''
if cmd.is_command:
cls = cmd.get_class()
cls = cmd.cmd_class
parser.syntax += ' ' + cls.syntax
parser.update_arguments(cls().arguments)
description = getattr(cls, 'long_description', '')
description = description.strip()
description = getattr(cls, 'long_description', '').strip()
else:
parser.syntax += ' <...>'
if cmd.has_description:
parser.parser.description = cmd.help + (
('\n%s' % description) if description else '')
else:
parser.parser.description = description
parser.parser.description = (
cmd.help + ('\n' if description else '')) if cmd.help else description
def print_error_message(cli_err):
......@@ -440,7 +436,7 @@ def exec_cmd(instance, cmd_args, help_method):
def get_command_group(unparsed, arguments):
groups = arguments['config'].get_groups()
groups = arguments['config'].groups
for term in unparsed:
if term.startswith('-'):
continue
......
......@@ -57,68 +57,29 @@ log = getLogger(__name__)
class Argument(object):
"""An argument that can be parsed from command line or otherwise.
This is the general Argument class. It is suggested to extent this
This is the top-level Argument class. It is suggested to extent this
class into more specific argument types.
"""
def __init__(self, arity, help=None, parsed_name=None, default=None):
self.arity = int(arity)
self.help = '%s' % help or ''
if help:
self.help = help
if parsed_name:
self.parsed_name = parsed_name
assert self.parsed_name, 'No parsed name for argument %s' % self
self.default = default
assert parsed_name, 'No parsed name for argument %s' % self
self.parsed_name = list(parsed_name) if isinstance(
parsed_name, list) or isinstance(parsed_name, tuple) else (
'%s' % parsed_name).split()
for name in self.parsed_name:
assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
self, name)
msg = '%s: Invalid parse name "%s" should start with a "-"' % (
self, name)
assert name.startswith('-'), msg
@property
def parsed_name(self):
"""the string which will be recognised by the parser as an instance
of this argument
"""
return getattr(self, '_parsed_name', None)
@parsed_name.setter
def parsed_name(self, newname):
self._parsed_name = getattr(self, '_parsed_name', [])
if isinstance(newname, list) or isinstance(newname, tuple):
self._parsed_name += list(newname)
else:
self._parsed_name.append('%s' % newname)
@property
def help(self):
"""a user friendly help message"""
return getattr(self, '_help', None)
@help.setter
def help(self, newhelp):
self._help = '%s' % newhelp
@property
def arity(self):
"""negative for repeating, 0 for flag, 1 or more for values"""
return getattr(self, '_arity', None)
@arity.setter
def arity(self, newarity):
newarity = int(newarity)
self._arity = newarity
@property
def default(self):
"""the value of this argument when not set"""
if not hasattr(self, '_default'):
self._default = False if self.arity == 0 else None
return self._default
@default.setter
def default(self, newdefault):
self._default = newdefault
self.default = default or (None if self.arity else False)
@property
def value(self):
"""the value of the argument"""
return getattr(self, '_value', self.default)
@value.setter
......@@ -127,39 +88,31 @@ class Argument(object):
def update_parser(self, parser, name):
"""Update argument parser with self info"""
action = 'append' if self.arity < 0\
else 'store_true' if self.arity == 0\
else 'store'
action = 'append' if self.arity < 0 else (
'store' if self.arity else 'store_true')
parser.add_argument(
*self.parsed_name,
dest=name,
action=action,
default=self.default,
help=self.help)
def main(self):
"""Overide this method to give functionality to your args"""
raise NotImplementedError
dest=name, action=action, default=self.default, help=self.help)
class ConfigArgument(Argument):
"""Manage a kamaki configuration (file)"""
_config_file = None
def __init__(self, help, parsed_name=('-c', '--config')):
super(ConfigArgument, self).__init__(1, help, parsed_name, None)
self.file_path = None
@property
def value(self):
"""A Config object"""
super(self.__class__, self).value
return super(self.__class__, self).value
return super(ConfigArgument, self).value
@value.setter
def value(self, config_file):
if config_file:
self._value = Config(config_file)
self._config_file = config_file
elif self._config_file:
self._value = Config(self._config_file)
self.file_path = config_file
elif self.file_path:
self._value = Config(self.file_path)
else:
self._value = Config()
......@@ -167,13 +120,15 @@ class ConfigArgument(Argument):
"""Get a configuration setting from the Config object"""
return self.value.get(group, term)
def get_groups(self):
@property
def groups(self):
suffix = '_cli'
slen = len(suffix)
return [term[:-slen] for term in self.value.keys('global') if (
term.endswith(suffix))]
def get_cli_specs(self):
@property
def cli_specs(self):
suffix = '_cli'
slen = len(suffix)
return [(k[:-slen], v) for k, v in self.value.items('global') if (
......@@ -185,8 +140,8 @@ class ConfigArgument(Argument):
def get_cloud(self, cloud, option):
return self.value.get_cloud(cloud, option)
_config_arg = ConfigArgument(
1, 'Path to configuration file', ('-c', '--config'))
_config_arg = ConfigArgument('Path to config file')
class CmdLineConfigArgument(Argument):
......@@ -337,11 +292,7 @@ class VersionArgument(FlagArgument):
@value.setter
def value(self, newvalue):
self._value = newvalue
self.main()
def main(self):
"""Print current version"""
if self.value:
if newvalue:
import kamaki
print('kamaki %s' % kamaki.__version__)
......
# Copyright 2013 GRNET S.A. All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above
# copyright notice, this list of conditions and the following
# disclaimer.
#
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials
# provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
#from mock import patch, call
from unittest import TestCase
from StringIO import StringIO
from sys import stdin, stdout
#from itertools import product
from kamaki.cli import argument
from kamaki.cli.config import Config
class Argument(TestCase):
def test___init__(self):
self.assertRaises(ValueError, argument.Argument, 'non-integer')
self.assertRaises(AssertionError, argument.Argument, 1)
self.assertRaises(AssertionError, argument.Argument, 0, 'noname')
self.assertRaises(AssertionError, argument.Argument, 0, '--no name')
self.assertRaises(AssertionError, argument.Argument, 0, ['-n', 'n m'])
for arity, help, parsed_name, default in (
(0, 'help 0', '--zero', None),
(1, 'help 1', ['--one', '-o'], 'lala'),
(-1, 'help -1', ['--help', '--or', '--more'], 0),
(0, 'help 0 again', ['--again', ], True)):
a = argument.Argument(arity, help, parsed_name, default)
if arity:
self.assertEqual(arity, a.arity)
self.assertEqual(help, a.help)
exp_name = parsed_name if (
isinstance(parsed_name, list)) else [parsed_name, ]
self.assertEqual(exp_name, a.parsed_name)
exp_default = default or (None if arity else False)
self.assertEqual(exp_default, a.default)
def test_value(self):
a = argument.Argument(1, parsed_name='--value')
for value in (None, '', 0, 0.1, -12, [1, 'a', 2.8], (3, 'lala'), 'pi'):
a.value = value
self.assertEqual(value, a.value)
def test_update_parser(self):
for i, arity in enumerate((-1, 0, 1)):
arp = argument.ArgumentParser()
pname, aname = '--pname%s' % i, 'a_name_%s' % i
a = argument.Argument(arity, 'args', pname, 42)
a.update_parser(arp, aname)
f = StringIO()
arp.print_usage(file=f), f.seek(0)
usage, exp = f.readline(), '[%s%s]\n' % (
pname, (' %s' % aname.upper()) if arity else '')
self.assertEqual(usage[-len(exp):], exp)
del arp
class ConfigArgument(TestCase):
# A cloud name in config with a URL but no TOKEN
SEMI_CLOUD = 'production'
# A cloud name that is not configured in config
INVALID_CLOUD = 'QWERTY_123456'
def setUp(self):
argument._config_arg = argument.ConfigArgument('Recovered Path')
def test_value(self):
c = argument._config_arg
self.assertEqual(c.value, None)
exp = '/some/random/path'
c.value = exp
self.assertTrue(isinstance(c.value, Config))
self.assertEqual(c.file_path, exp)
self.assertEqual(c.value.path, exp)
def test_get(self):
c = argument._config_arg
c.value = None
self.assertEqual(c.value.get('global', 'config_cli'), 'config')
def test_groups(self):
c = argument._config_arg
c.value = None
self.assertTrue(set(c.groups).issuperset([
'image', 'config', 'history']))
def test_cli_specs(self):
c = argument._config_arg
c.value = None
self.assertTrue(set(c.cli_specs).issuperset([
('image', 'image'), ('config', 'config'), ('history', 'history')]))
def test_get_global(self):
c = argument._config_arg
c.value = None
for k, v in (
('config_cli', 'config'),
('image_cli', 'image'),
('history_cli', 'history')):
self.assertEqual(c.get_global(k), v)
def test_get_cloud(self):
"""test_get_cloud (!! hard-set SEMI/INVALID_CLOUD to run this !!)"""
c = argument._config_arg
c.value = None
if not self.SEMI_CLOUD:
stdout.write(
'\n\tA cloud name set in config file with URL but no TOKEN: ')
self.SEMI_CLOUD = stdin.readline()[:-1]
self.assertTrue(len(c.get_cloud(self.SEMI_CLOUD, 'url')) > 0)
self.assertRaises(KeyError, c.get_cloud, self.SEMI_CLOUD, 'token')
if not self.INVALID_CLOUD:
stdout.write('\tok\n\tA cloud name NOT in your config file: ')
self.INVALID_CLOUD = stdin.readline()[:-1]
self.assertRaises(KeyError, c.get_cloud, self.INVALID_CLOUD, 'url')
if __name__ == '__main__':
from sys import argv
from kamaki.cli.test import runTestCase
runTestCase(Argument, 'Argument', argv[1:])
runTestCase(ConfigArgument, 'ConfigArgument', argv[1:])
......@@ -150,7 +150,7 @@ class Shell(Cmd):
pass
def _roll_command(self, cmd_path=None):
for subname in self.cmd_tree.get_subnames(cmd_path):
for subname in self.cmd_tree.subnames(cmd_path):
self._unregister_method('do_%s' % subname)
self._unregister_method('complete_%s' % subname)
self._unregister_method('help_%s' % subname)
......@@ -197,7 +197,7 @@ class Shell(Cmd):
# exec command or change context
if subcmd.is_command: # exec command
try:
cls = subcmd.get_class()
cls = subcmd.cmd_class
ldescr = getattr(cls, 'long_description', '')
if subcmd.path == 'history_run':
instance = cls(
......@@ -241,7 +241,7 @@ class Shell(Cmd):
old_prompt = self.prompt
new_context._roll_command(cmd.parent_path)
new_context.set_prompt(subcmd.path.replace('_', ' '))
newcmds = [subcmd for subcmd in subcmd.get_subcommands()]
newcmds = [subcmd for subcmd in subcmd.subcommands.values()]
for subcmd in newcmds:
new_context._register_command(subcmd.path)
new_context.cmdloop()
......@@ -253,7 +253,7 @@ class Shell(Cmd):
def help_method(self):
print('%s (%s -h for more options)' % (cmd.help, cmd.name))
if cmd.is_command:
cls = cmd.get_class()
cls = cmd.cmd_class
ldescr = getattr(cls, 'long_description', '')
#_construct_command_syntax(cls)
plist = self.prompt[len(self._prefix):-len(self._suffix)]
......@@ -277,18 +277,18 @@ class Shell(Cmd):
def complete_method(self, text, line, begidx, endidx):
subcmd, cmd_args = cmd.parse_out(split_input(line)[1:])
if subcmd.is_command:
cls = subcmd.get_class()
cls = subcmd.cmd_class
instance = cls(dict(arguments))
empty, sep, subname = subcmd.path.partition(cmd.path)
cmd_name = '%s %s' % (cmd.name, subname.replace('_', ' '))
print('\n%s\nSyntax:\t%s %s' % (
cls.description, cmd_name, cls.syntax))
cls.help, cmd_name, cls.syntax))
cmd_args = {}
for arg in instance.arguments.values():
cmd_args[','.join(arg.parsed_name)] = arg.help
print_dict(cmd_args, indent=2)
stdout.write('%s %s' % (self.prompt, line))
return subcmd.get_subnames()
return subcmd.subnames()
self._register_method(complete_method, 'complete_%s' % cmd.name)
@property
......@@ -310,8 +310,8 @@ class Shell(Cmd):
else:
intro = self.cmd_tree.name
acceptable = parser.arguments['config'].get_groups()
total = self.cmd_tree.get_group_names()
acceptable = parser.arguments['config'].groups
total = self.cmd_tree.groups.keys()
self.cmd_tree.exclude(set(total).difference(acceptable))
for subcmd in self.cmd_tree.get_subcommands(path):
......
......@@ -31,8 +31,6 @@
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
from kamaki.clients import Client
class Command(object):
"""Store a command and the next-level (2 levels)"""
......@@ -43,14 +41,15 @@ class Command(object):
help = ' '
def __init__(self, path, help=' ', subcommands={}, cmd_class=None):
assert path, 'Cannot initialize a command without a command path'
self.path = path
self.help = help
self.subcommands = dict(subcommands)
self.cmd_class = cmd_class
self.help = help or ''
self.subcommands = dict(subcommands) if subcommands else {}
self.cmd_class = cmd_class or None
@property
def name(self):
if self._name is None:
if not self._name:
self._name = self.path.split('_')[-1]
return str(self._name)
......@@ -72,40 +71,25 @@ class Command(object):
@property
def is_command(self):
return self.cmd_class is not None and len(self.subcommands) == 0
@property
def has_description(self):
return len(self.help.strip()) > 0
@property
def description(self):
return self.help
return len(self.subcommands) == 0 if self.cmd_class else False
@property
def parent_path(self):
parentpath, sep, name = self.path.rpartition('_')
return parentpath
def set_class(self, cmd_class):
self.cmd_class = cmd_class
def get_class(self):
return self.cmd_class
def has_subname(self, subname):
return subname in self.subcommands
try:
return self.path[:self.path.rindex('_')]
except ValueError:
return ''
def get_subnames(self):
return self.subcommands.keys()
def parse_out(self, args):
"""Find the deepest subcommand matching a series of terms
but stop the first time a term doesn't match
def get_subcommands(self):
return self.subcommands.values()
:param args: (list) terms to match commands against
def sublen(self):
return len(self.subcommands)
:returns: (parsed out command, the rest of the arguments)
def parse_out(self, args):
:raises TypeError: if args is not inalterable
"""
cmd = self
index = 0
for term in args:
......@@ -117,25 +101,19 @@ class Command(object):
return cmd, args[index:]
def pretty_print(self, recursive=False):
print('Path: %s (Name: %s) is_cmd: %s\n\thelp: %s' % (
self.path,
self.name,
self.is_command,
self.help))
for cmd in self.get_subcommands():
print('%s\t\t(Name: %s is_cmd: %s help: %s)' % (
self.path, self.name, self.is_command, self.help))
for cmd in self.subcommands.values():
cmd.pretty_print(recursive)
class CommandTree(object):
groups = {}
_all_commands = {}
name = None
description = None
def __init__(self, name, description=''):
self.name = name
self.description = description
self.groups = dict()
self._all_commands = dict()
def exclude(self, groups_to_exclude=[]):
for group in groups_to_exclude:
......@@ -159,17 +137,17 @@ class CommandTree(object):
self._all_commands[path] = new_cmd
cmd.add_subcmd(new_cmd)
cmd = new_cmd
if cmd_class:
cmd.set_class(cmd_class)
if description is not None:
cmd.help = description
cmd.cmd_class = cmd_class or None
cmd.help = description or None
def find_best_match(self, terms):
"""Find a command that best matches a given list of terms
:param terms: (list of str) match them against paths in cmd_tree
:param terms: (list of str) match against paths in cmd_tree, e.g.
['aa', 'bb', 'cc'] matches aa_bb_cc
:returns: (Command, list) the matching command, the remaining terms
:returns: (Command, list) the matching command, the remaining terms or