Attached is the CLI code tarball. It is documented at http://test.kernel.org/autotest/CLIHowTo
From: [email protected]
git-svn-id: http://test.kernel.org/svn/autotest/trunk@1950 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/cli/topic_common.py b/cli/topic_common.py
new file mode 100755
index 0000000..4d94a0e
--- /dev/null
+++ b/cli/topic_common.py
@@ -0,0 +1,552 @@
+#
+# Copyright 2008 Google Inc. All Rights Reserved.
+#
+"""
+This module contains the generic CLI object
+
+High Level Design:
+
+The atest class contains attributes & method generic to all the CLI
+operations.
+
+The class inheritance is shown here using the command
+'atest host create ...' as an example:
+
+atest <-- host <-- host_create <-- site_host_create
+
+Note: The site_<topic>.py and its classes are only needed if you need
+to override the common <topic>.py methods with your site specific ones.
+
+
+High Level Algorithm:
+
+1. atest figures out the topic and action from the 2 first arguments
+ on the command line and imports the <topic> (or site_<topic>)
+ module.
+
+1. Init
+ The main atest module creates a <topic>_<action> object. The
+ __init__() function is used to setup the parser options, if this
+ <action> has some specific options to add to its <topic>.
+
+ If it exists, the child __init__() method must call its parent
+ class __init__() before adding its own parser arguments.
+
+2. Parsing
+ If the child wants to validate the parsing (e.g. make sure that
+ there are hosts in the arguments), or if it wants to check the
+ options it added in its __init__(), it should implement a parse()
+ method.
+
+ The child parser must call its parent parser and gets back the
+ options dictionary and the rest of the command line arguments
+ (leftover). Each level gets to see all the options, but the
+ leftovers can be deleted as they can be consumed by only one
+ object.
+
+3. Execution
+ This execute() method is specific to the child and should use the
+ self.execute_rpc() to send commands to the Autotest Front-End. It
+ should return results.
+
+4. Output
+ The child output() method is called with the execute() resutls as a
+ parameter. This is child-specific, but should leverage the
+ atest.print_*() methods.
+"""
+
+import os, sys, pwd, optparse, re, textwrap, urllib2, getpass, socket
+from autotest_lib.cli import rpc
+from autotest_lib.frontend.afe.json_rpc import proxy
+
+
+# Maps the AFE keys to printable names.
+KEYS_TO_NAMES_EN = {'hostname': 'Host',
+ 'platform': 'Platform',
+ 'status': 'Status',
+ 'locked': 'Locked',
+ 'locked_by': 'Locked by',
+ 'labels': 'Labels',
+ 'description': 'Description',
+ 'hosts': 'Hosts',
+ 'users': 'Users',
+ 'id': 'Id',
+ 'name': 'Name',
+ 'invalid': 'Valid',
+ 'login': 'Login',
+ 'access_level': 'Access Level',
+ 'job_id': 'Job Id',
+ 'job_owner': 'Job Owner',
+ 'job_name': 'Job Name',
+ 'test_type': 'Test Type',
+ 'test_class': 'Test Class',
+ 'path': 'Path',
+ 'owner': 'Owner',
+ 'status_counts': 'Status Counts',
+ 'hosts_status': 'Host Status',
+ 'priority': 'Priority',
+ 'control_type': 'Control Type',
+ 'created_on': 'Created On',
+ 'synch_type': 'Synch Type',
+ 'control_file': 'Control File',
+ }
+
+# In the failure, tag that will replace the item.
+FAIL_TAG = '<XYZ>'
+
+# Global socket timeout
+DEFAULT_SOCKET_TIMEOUT = 5
+# For list commands, can take longer
+LIST_SOCKET_TIMEOUT = 30
+# For uploading kernels, can take much, much longer
+UPLOAD_SOCKET_TIMEOUT = 60*30
+
+
+# Convertion functions to be called for printing,
+# e.g. to print True/False for booleans.
+def __convert_platform(field):
+ if not field:
+ # Can be None
+ return ""
+ elif type(field) == int:
+ # Can be 0/1 for False/True
+ return str(bool(field))
+ else:
+ # Can be a platform name
+ return field
+
+
+KEYS_CONVERT = {'locked': lambda flag: str(bool(flag)),
+ 'invalid': lambda flag: str(bool(not flag)),
+ 'platform': __convert_platform,
+ 'labels': lambda labels: ', '.join(labels)}
+
+class CliError(Exception):
+ pass
+
+
+class atest(object):
+ """Common class for generic processing
+ Should only be instantiated by itself for usage
+ references, otherwise, the <topic> objects should
+ be used."""
+ msg_topic = "[acl|host|job|label|user]"
+ usage_action = "[action]"
+ msg_items = ''
+
+ def invalid_arg(self, header, follow_up=''):
+ twrap = textwrap.TextWrapper(initial_indent=' ',
+ subsequent_indent=' ')
+ rest = twrap.fill(follow_up)
+
+ if self.kill_on_failure:
+ self.invalid_syntax(header + rest)
+ else:
+ print >> sys.stderr, header + rest
+
+
+ def invalid_syntax(self, msg):
+ print
+ print >> sys.stderr, msg
+ print
+ print "usage:",
+ print self._get_usage()
+ print
+ sys.exit(1)
+
+
+ def generic_error(self, msg):
+ print >> sys.stderr, msg
+ sys.exit(1)
+
+
+ def failure(self, full_error, item=None, what_failed=''):
+ """If kill_on_failure, print this error and die,
+ otherwise, queue the error and accumulate all the items
+ that triggered the same error."""
+
+ if self.debug:
+ errmsg = str(full_error)
+ else:
+ errmsg = str(full_error).split('Traceback')[0].rstrip('\n')
+
+ if self.kill_on_failure:
+ print >> sys.stderr, "%s\n %s" % (what_failed, errmsg)
+ sys.exit(1)
+
+ # Build a dictionary with the 'what_failed' as keys. The
+ # values are dictionaries with the errmsg as keys and a set
+ # of items as values.
+ # self.failed =
+ # {'Operation delete_host_failed': {'AclAccessViolation:
+ # set('host0', 'host1')}}
+ # Try to gather all the same error messages together,
+ # even if they contain the 'item'
+ if item and item in errmsg:
+ errmsg = errmsg.replace(item, FAIL_TAG)
+ if self.failed.has_key(what_failed):
+ self.failed[what_failed].setdefault(errmsg, set()).add(item)
+ else:
+ self.failed[what_failed] = {errmsg: set([item])}
+
+
+ def show_all_failures(self):
+ if not self.failed:
+ return 0
+ for what_failed in self.failed.keys():
+ print >> sys.stderr, what_failed + ':'
+ for (errmsg, items) in self.failed[what_failed].iteritems():
+ if len(items) == 0:
+ print >> sys.stderr, errmsg
+ elif items == set(['']):
+ print >> sys.stderr, ' ' + errmsg
+ elif len(items) == 1:
+ # Restore the only item
+ if FAIL_TAG in errmsg:
+ errmsg = errmsg.replace(FAIL_TAG, items.pop())
+ else:
+ errmsg = '%s (%s)' % (errmsg, items.pop())
+ print >> sys.stderr, ' ' + errmsg
+ else:
+ print >> sys.stderr, ' ' + errmsg + ' with <XYZ> in:'
+ twrap = textwrap.TextWrapper(initial_indent=' ',
+ subsequent_indent=' ')
+ items = list(items)
+ items.sort()
+ print >> sys.stderr, twrap.fill(', '.join(items))
+ return 1
+
+
+ def __init__(self):
+ """Setup the parser common options"""
+ # Initialized for unit tests.
+ self.afe = None
+ self.failed = {}
+ self.data = {}
+ self.debug = False
+ self.kill_on_failure = False
+ self.web_server = ''
+ self.verbose = False
+
+ self.parser = optparse.OptionParser(self._get_usage())
+ self.parser.add_option('-g', '--debug',
+ help='Print debugging information',
+ action='store_true', default=False)
+ self.parser.add_option('--kill-on-failure',
+ help='Stop at the first failure',
+ action='store_true', default=False)
+ self.parser.add_option('--parse',
+ help='Print the output using colon '
+ 'separated key=value fields',
+ action='store_true', default=False)
+ self.parser.add_option('-v', '--verbose',
+ action='store_true', default=False)
+ self.parser.add_option('-w', '--web',
+ help='Specify the autotest server '
+ 'to talk to',
+ action='store', type='string',
+ dest='web_server', default=None)
+
+ # Shorten the TCP timeout.
+ socket.setdefaulttimeout(DEFAULT_SOCKET_TIMEOUT)
+
+
+ def _file_list(self, options, opt_file='', opt_list='', add_on=[]):
+ """Returns a list containing the unique items from the
+ options.<opt_list>, from the file options.<opt_file>,
+ and from the space separated add_on strings.
+ The opt_list can be space or comma separated list.
+ Used for host, acls, labels... arguments"""
+ # Start with the add_on
+ result = set()
+ [result.add(item)
+ for items in add_on
+ for item in items.split(',')]
+
+ # Process the opt_list, if any
+ try:
+ args = getattr(options, opt_list)
+ [result.add(arg) for arg in re.split(r'[\s,]', args)]
+ except (AttributeError, TypeError):
+ pass
+
+ # Process the file list, if any and not empty
+ # The file can contain space and/or comma separated items
+ try:
+ flist = getattr(options, opt_file)
+ file_content = []
+ for line in open(flist).readlines():
+ if line == '\n':
+ continue
+ file_content += re.split(r'[\s,]',
+ line.rstrip('\n'))
+ if len(file_content) == 0:
+ self.invalid_syntax("Empty file %s" % flist)
+ result = result.union(file_content)
+ except (AttributeError, TypeError):
+ pass
+ except IOError:
+ self.invalid_syntax("Could not open file %s" % flist)
+
+ return list(result)
+
+
+ def _get_usage(self):
+ return "atest %s %s [options] %s" % (self.msg_topic.lower(),
+ self.usage_action,
+ self.msg_items)
+
+
+ def parse_with_flist(self, flists, req_items):
+ """Flists is a list of tuples containing:
+ (attribute, opt_fname, opt_list, use_leftover)
+
+ self.<atttribute> will be populated with a set
+ containing the lines of the file named
+ options.<opt_fname> and the options.<opt_list> values
+ and the leftover from the parsing if use_leftover is
+ True. There should only be one use_leftover set to
+ True in the list.
+ Also check if the req_items is not empty after parsing."""
+ (options, leftover) = atest.parse(self)
+ if leftover == None:
+ leftover = []
+
+ for (attribute, opt_fname, opt_list, use_leftover) in flists:
+ if use_leftover:
+ add_on = leftover
+ leftover = []
+ else:
+ add_on = []
+
+ setattr(self, attribute,
+ self._file_list(options,
+ opt_file=opt_fname,
+ opt_list=opt_list,
+ add_on=add_on))
+
+ if (req_items and not getattr(self, req_items, None)):
+ self.invalid_syntax('%s %s requires at least one %s' %
+ (self.msg_topic,
+ self.usage_action,
+ self.msg_topic))
+
+ return (options, leftover)
+
+
+ def parse(self):
+ """Parse all the arguments.
+
+ It consumes what the common object needs to know, and
+ let the children look at all the options. We could
+ remove the options that we have used, but there is no
+ harm in leaving them, and the children may need them
+ in the future.
+
+ Must be called from its children parse()"""
+ (options, leftover) = self.parser.parse_args()
+ # Handle our own options setup in __init__()
+ self.debug = options.debug
+ self.kill_on_failure = options.kill_on_failure
+
+ if options.parse:
+ suffix = '_parse'
+ else:
+ suffix = '_std'
+ for func in ['print_fields', 'print_table',
+ 'print_by_ids']:
+ setattr(self, func, getattr(self, func + suffix))
+
+ self.verbose = options.verbose
+ self.web_server = options.web_server
+ self.afe = rpc.afe_comm(self.web_server)
+
+ return (options, leftover)
+
+
+ def check_and_create_items(self, op_get, op_create,
+ items, **data_create):
+ """Create the items if they don't exist already"""
+ for item in items:
+ ret = self.execute_rpc(op_get, name=item)
+
+ if len(ret) == 0:
+ try:
+ data_create['name'] = item
+ self.execute_rpc(op_create, **data_create)
+ except CliError:
+ continue
+
+
+ def execute_rpc(self, op, item='', **data):
+ retry = 2
+ while retry:
+ try:
+ return self.afe.run(op, **data)
+ except urllib2.URLError, err:
+ if 'timed out' not in err.reason:
+ self.invalid_syntax('Invalid server name %s: %s' %
+ (self.afe.web_server, err))
+ if self.debug:
+ print 'retrying: %r %d' % (data, retry)
+ retry -= 1
+ if retry == 0:
+ if item:
+ myerr = '%s timed out for %s' % (op, item)
+ else:
+ myerr = '%s timed out' % op
+ self.failure(myerr, item=item,
+ what_failed=("Timed-out contacting "
+ "the Autotest server"))
+ raise CliError("Timed-out contacting the Autotest server")
+ except Exception, full_error:
+ # There are various exceptions throwns by JSON,
+ # urllib & httplib, so catch them all.
+ self.failure(full_error, item=item,
+ what_failed='Operation %s failed' % op)
+ raise CliError(str(full_error))
+
+
+ # There is no output() method in the atest object (yet?)
+ # but here are some helper functions to be used by its
+ # children
+ def print_wrapped(self, msg, values):
+ if len(values) == 0:
+ return
+ elif len(values) == 1:
+ print msg + ': '
+ elif len(values) > 1:
+ if msg.endswith('s'):
+ print msg + ': '
+ else:
+ print msg + 's: '
+
+ values.sort()
+ twrap = textwrap.TextWrapper(initial_indent='\t',
+ subsequent_indent='\t')
+ print twrap.fill(', '.join(values))
+
+
+ def __conv_value(self, type, value):
+ return KEYS_CONVERT.get(type, str)(value)
+
+
+ def print_fields_std(self, items, keys, title=None):
+ """Print the keys in each item, one on each line"""
+ if not items:
+ print "No results"
+ return
+ if title:
+ print title
+ for item in items:
+ for key in keys:
+ print '%s: %s' % (KEYS_TO_NAMES_EN[key],
+ self.__conv_value(key,
+ item[key]))
+
+
+ def print_fields_parse(self, items, keys, title=None):
+ """Print the keys in each item as comma
+ separated name=value"""
+ if not items:
+ print "No results"
+ return
+ for item in items:
+ values = ['%s=%s' % (KEYS_TO_NAMES_EN[key],
+ self.__conv_value(key,
+ item[key]))
+ for key in keys
+ if self.__conv_value(key,
+ item[key]) != '']
+ print ':'.join(values)
+
+
+ def __find_justified_fmt(self, items, keys):
+ """Find the max length for each field."""
+ lens = {}
+ # Don't justify the last field, otherwise we have blank
+ # lines when the max is overlaps but the current values
+ # are smaller
+ if not items:
+ print "No results"
+ return
+ for key in keys[:-1]:
+ lens[key] = max(len(self.__conv_value(key,
+ item[key]))
+ for item in items)
+ lens[key] = max(lens[key], len(KEYS_TO_NAMES_EN[key]))
+ lens[keys[-1]] = 0
+
+ return ' '.join(["%%-%ds" % lens[key] for key in keys])
+
+
+ def print_table_std(self, items, keys_header, sublist_keys={}):
+ """Print a mix of header and lists in a user readable
+ format
+ The headers are justified, the sublist_keys are wrapped."""
+ if not items:
+ print "No results"
+ return
+ fmt = self.__find_justified_fmt(items, keys_header)
+ header = tuple(KEYS_TO_NAMES_EN[key] for key in keys_header)
+ print fmt % header
+ for item in items:
+ values = tuple(self.__conv_value(key, item[key])
+ for key in keys_header)
+ print fmt % values
+ if self.verbose and sublist_keys:
+ for key in sublist_keys:
+ self.print_wrapped(KEYS_TO_NAMES_EN[key],
+ item[key])
+ print '\n'
+
+
+ def print_table_parse(self, items, keys_header, sublist_keys=[]):
+ """Print a mix of header and lists in a user readable
+ format"""
+ if not items:
+ print "No results"
+ return
+ for item in items:
+ values = ['%s=%s' % (KEYS_TO_NAMES_EN[key],
+ self.__conv_value(key, item[key]))
+ for key in keys_header
+ if self.__conv_value(key,
+ item[key]) != '']
+
+ if self.verbose:
+ [values.append('%s=%s'% (KEYS_TO_NAMES_EN[key],
+ ','.join(item[key])))
+ for key in sublist_keys
+ if len(item[key])]
+
+ print ':'.join(values)
+
+
+ def print_by_ids_std(self, items, title=None, line_before=False):
+ """Prints ID & names of items in a user readable form"""
+ if not items:
+ return
+ if line_before:
+ print
+ if title:
+ print title + ':'
+ self.print_table_std(items, keys_header=['id', 'name'])
+
+
+ def print_by_ids_parse(self, items, title=None, line_before=False):
+ """Prints ID & names of items in a parseable format"""
+ if not items:
+ print "No results"
+ return
+ if title:
+ print title + '=',
+ values = []
+ for item in items:
+ values += ['%s=%s' % (KEYS_TO_NAMES_EN[key],
+ self.__conv_value(key,
+ item[key]))
+ for key in ['id', 'name']
+ if self.__conv_value(key,
+ item[key]) != '']
+ print ':'.join(values)