• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#
2# Copyright 2008 Google Inc. All Rights Reserved.
3#
4"""
5This module contains the generic CLI object
6
7High Level Design:
8
9The atest class contains attributes & method generic to all the CLI
10operations.
11
12The class inheritance is shown here using the command
13'atest host create ...' as an example:
14
15atest <-- host <-- host_create <-- site_host_create
16
17Note: The site_<topic>.py and its classes are only needed if you need
18to override the common <topic>.py methods with your site specific ones.
19
20
21High Level Algorithm:
22
231. atest figures out the topic and action from the 2 first arguments
24   on the command line and imports the <topic> (or site_<topic>)
25   module.
26
271. Init
28   The main atest module creates a <topic>_<action> object.  The
29   __init__() function is used to setup the parser options, if this
30   <action> has some specific options to add to its <topic>.
31
32   If it exists, the child __init__() method must call its parent
33   class __init__() before adding its own parser arguments.
34
352. Parsing
36   If the child wants to validate the parsing (e.g. make sure that
37   there are hosts in the arguments), or if it wants to check the
38   options it added in its __init__(), it should implement a parse()
39   method.
40
41   The child parser must call its parent parser and gets back the
42   options dictionary and the rest of the command line arguments
43   (leftover). Each level gets to see all the options, but the
44   leftovers can be deleted as they can be consumed by only one
45   object.
46
473. Execution
48   This execute() method is specific to the child and should use the
49   self.execute_rpc() to send commands to the Autotest Front-End.  It
50   should return results.
51
524. Output
53   The child output() method is called with the execute() resutls as a
54   parameter.  This is child-specific, but should leverage the
55   atest.print_*() methods.
56"""
57
58import logging
59import optparse
60import os
61import re
62import sys
63import textwrap
64import traceback
65import urllib2
66
67import common
68
69from autotest_lib.cli import rpc
70from autotest_lib.cli import skylab_utils
71from autotest_lib.client.common_lib.test_utils import mock
72from autotest_lib.client.common_lib import autotemp
73
74skylab_inventory_imported = False
75try:
76    from skylab_inventory import translation_utils
77    skylab_inventory_imported = True
78except ImportError:
79    pass
80
81
82# Maps the AFE keys to printable names.
83KEYS_TO_NAMES_EN = {'hostname': 'Host',
84                    'platform': 'Platform',
85                    'status': 'Status',
86                    'locked': 'Locked',
87                    'locked_by': 'Locked by',
88                    'lock_time': 'Locked time',
89                    'lock_reason': 'Lock Reason',
90                    'labels': 'Labels',
91                    'description': 'Description',
92                    'hosts': 'Hosts',
93                    'users': 'Users',
94                    'id': 'Id',
95                    'name': 'Name',
96                    'invalid': 'Valid',
97                    'login': 'Login',
98                    'access_level': 'Access Level',
99                    'job_id': 'Job Id',
100                    'job_owner': 'Job Owner',
101                    'job_name': 'Job Name',
102                    'test_type': 'Test Type',
103                    'test_class': 'Test Class',
104                    'path': 'Path',
105                    'owner': 'Owner',
106                    'status_counts': 'Status Counts',
107                    'hosts_status': 'Host Status',
108                    'hosts_selected_status': 'Hosts filtered by Status',
109                    'priority': 'Priority',
110                    'control_type': 'Control Type',
111                    'created_on': 'Created On',
112                    'control_file': 'Control File',
113                    'only_if_needed': 'Use only if needed',
114                    'protection': 'Protection',
115                    'run_verify': 'Run verify',
116                    'reboot_before': 'Pre-job reboot',
117                    'reboot_after': 'Post-job reboot',
118                    'experimental': 'Experimental',
119                    'synch_count': 'Sync Count',
120                    'max_number_of_machines': 'Max. hosts to use',
121                    'parse_failed_repair': 'Include failed repair results',
122                    'shard': 'Shard',
123                    }
124
125# In the failure, tag that will replace the item.
126FAIL_TAG = '<XYZ>'
127
128# Global socket timeout: uploading kernels can take much,
129# much longer than the default
130UPLOAD_SOCKET_TIMEOUT = 60*30
131
132LOGGING_LEVEL_MAP = {
133      'CRITICAL': logging.CRITICAL,
134      'ERROR': logging.ERROR,
135      'WARNING': logging.WARNING,
136      'INFO': logging.INFO,
137      'DEBUG': logging.DEBUG,
138}
139
140
141# Convertion functions to be called for printing,
142# e.g. to print True/False for booleans.
143def __convert_platform(field):
144    if field is None:
145        return ""
146    elif isinstance(field, int):
147        # Can be 0/1 for False/True
148        return str(bool(field))
149    else:
150        # Can be a platform name
151        return field
152
153
154def _int_2_bool_string(value):
155    return str(bool(value))
156
157KEYS_CONVERT = {'locked': _int_2_bool_string,
158                'invalid': lambda flag: str(bool(not flag)),
159                'only_if_needed': _int_2_bool_string,
160                'platform': __convert_platform,
161                'labels': lambda labels: ', '.join(labels),
162                'shards': lambda shard: shard.hostname if shard else ''}
163
164
165def _get_item_key(item, key):
166    """Allow for lookups in nested dictionaries using '.'s within a key."""
167    if key in item:
168        return item[key]
169    nested_item = item
170    for subkey in key.split('.'):
171        if not subkey:
172            raise ValueError('empty subkey in %r' % key)
173        try:
174            nested_item = nested_item[subkey]
175        except KeyError, e:
176            raise KeyError('%r - looking up key %r in %r' %
177                           (e, key, nested_item))
178    else:
179        return nested_item
180
181
182class CliError(Exception):
183    """Error raised by cli calls.
184    """
185    pass
186
187
188class item_parse_info(object):
189    """Object keeping track of the parsing options.
190    """
191
192    def __init__(self, attribute_name, inline_option='',
193                 filename_option='', use_leftover=False):
194        """Object keeping track of the parsing options that will
195        make up the content of the atest attribute:
196        attribute_name: the atest attribute name to populate    (label)
197        inline_option: the option containing the items           (--label)
198        filename_option: the option containing the filename      (--blist)
199        use_leftover: whether to add the leftover arguments or not."""
200        self.attribute_name = attribute_name
201        self.filename_option = filename_option
202        self.inline_option = inline_option
203        self.use_leftover = use_leftover
204
205
206    def get_values(self, options, leftover=[]):
207        """Returns the value for that attribute by accumualting all
208        the values found through the inline option, the parsing of the
209        file and the leftover"""
210
211        def __get_items(input, split_spaces=True):
212            """Splits a string of comma separated items. Escaped commas will not
213            be split. I.e. Splitting 'a, b\,c, d' will yield ['a', 'b,c', 'd'].
214            If split_spaces is set to False spaces will not be split. I.e.
215            Splitting 'a b, c\,d, e' will yield ['a b', 'c,d', 'e']"""
216
217            # Replace escaped slashes with null characters so we don't misparse
218            # proceeding commas.
219            input = input.replace(r'\\', '\0')
220
221            # Split on commas which are not preceded by a slash.
222            if not split_spaces:
223                split = re.split(r'(?<!\\),', input)
224            else:
225                split = re.split(r'(?<!\\),|\s', input)
226
227            # Convert null characters to single slashes and escaped commas to
228            # just plain commas.
229            return (item.strip().replace('\0', '\\').replace(r'\,', ',') for
230                    item in split if item.strip())
231
232        if self.use_leftover:
233            add_on = leftover
234            leftover = []
235        else:
236            add_on = []
237
238        # Start with the add_on
239        result = set()
240        for items in add_on:
241            # Don't split on space here because the add-on
242            # may have some spaces (like the job name)
243            result.update(__get_items(items, split_spaces=False))
244
245        # Process the inline_option, if any
246        try:
247            items = getattr(options, self.inline_option)
248            result.update(__get_items(items))
249        except (AttributeError, TypeError):
250            pass
251
252        # Process the file list, if any and not empty
253        # The file can contain space and/or comma separated items
254        try:
255            flist = getattr(options, self.filename_option)
256            file_content = []
257            for line in open(flist).readlines():
258                file_content += __get_items(line)
259            if len(file_content) == 0:
260                raise CliError("Empty file %s" % flist)
261            result.update(file_content)
262        except (AttributeError, TypeError):
263            pass
264        except IOError:
265            raise CliError("Could not open file %s" % flist)
266
267        return list(result), leftover
268
269
270class atest(object):
271    """Common class for generic processing
272    Should only be instantiated by itself for usage
273    references, otherwise, the <topic> objects should
274    be used."""
275    msg_topic = '[acl|host|job|label|shard|test|user|server]'
276    usage_action = '[action]'
277    msg_items = ''
278
279    def invalid_arg(self, header, follow_up=''):
280        """Fail the command with error that command line has invalid argument.
281
282        @param header: Header of the error message.
283        @param follow_up: Extra error message, default to empty string.
284        """
285        twrap = textwrap.TextWrapper(initial_indent='        ',
286                                     subsequent_indent='       ')
287        rest = twrap.fill(follow_up)
288
289        if self.kill_on_failure:
290            self.invalid_syntax(header + rest)
291        else:
292            print >> sys.stderr, header + rest
293
294
295    def invalid_syntax(self, msg):
296        """Fail the command with error that the command line syntax is wrong.
297
298        @param msg: Error message.
299        """
300        print
301        print >> sys.stderr, msg
302        print
303        print "usage:",
304        print self._get_usage()
305        print
306        sys.exit(1)
307
308
309    def generic_error(self, msg):
310        """Fail the command with a generic error.
311
312        @param msg: Error message.
313        """
314        if self.debug:
315            traceback.print_exc()
316        print >> sys.stderr, msg
317        sys.exit(1)
318
319
320    def parse_json_exception(self, full_error):
321        """Parses the JSON exception to extract the bad
322        items and returns them
323        This is very kludgy for the moment, but we would need
324        to refactor the exceptions sent from the front end
325        to make this better.
326
327        @param full_error: The complete error message.
328        """
329        errmsg = str(full_error).split('Traceback')[0].rstrip('\n')
330        parts = errmsg.split(':')
331        # Kludge: If there are 2 colons the last parts contains
332        # the items that failed.
333        if len(parts) != 3:
334            return []
335        return [item.strip() for item in parts[2].split(',') if item.strip()]
336
337
338    def failure(self, full_error, item=None, what_failed='', fatal=False):
339        """If kill_on_failure, print this error and die,
340        otherwise, queue the error and accumulate all the items
341        that triggered the same error.
342
343        @param full_error: The complete error message.
344        @param item: Name of the actionable item, e.g., hostname.
345        @param what_failed: Name of the failed item.
346        @param fatal: True to exit the program with failure.
347        """
348
349        if self.debug:
350            errmsg = str(full_error)
351        else:
352            errmsg = str(full_error).split('Traceback')[0].rstrip('\n')
353
354        if self.kill_on_failure or fatal:
355            print >> sys.stderr, "%s\n    %s" % (what_failed, errmsg)
356            sys.exit(1)
357
358        # Build a dictionary with the 'what_failed' as keys.  The
359        # values are dictionaries with the errmsg as keys and a set
360        # of items as values.
361        # self.failed =
362        # {'Operation delete_host_failed': {'AclAccessViolation:
363        #                                        set('host0', 'host1')}}
364        # Try to gather all the same error messages together,
365        # even if they contain the 'item'
366        if item and item in errmsg:
367            errmsg = errmsg.replace(item, FAIL_TAG)
368        if self.failed.has_key(what_failed):
369            self.failed[what_failed].setdefault(errmsg, set()).add(item)
370        else:
371            self.failed[what_failed] = {errmsg: set([item])}
372
373
374    def show_all_failures(self):
375        """Print all failure information.
376        """
377        if not self.failed:
378            return 0
379        for what_failed in self.failed.keys():
380            print >> sys.stderr, what_failed + ':'
381            for (errmsg, items) in self.failed[what_failed].iteritems():
382                if len(items) == 0:
383                    print >> sys.stderr, errmsg
384                elif items == set(['']):
385                    print >> sys.stderr, '    ' + errmsg
386                elif len(items) == 1:
387                    # Restore the only item
388                    if FAIL_TAG in errmsg:
389                        errmsg = errmsg.replace(FAIL_TAG, items.pop())
390                    else:
391                        errmsg = '%s (%s)' % (errmsg, items.pop())
392                    print >> sys.stderr, '    ' + errmsg
393                else:
394                    print >> sys.stderr, '    ' + errmsg + ' with <XYZ> in:'
395                    twrap = textwrap.TextWrapper(initial_indent='        ',
396                                                 subsequent_indent='        ')
397                    items = list(items)
398                    items.sort()
399                    print >> sys.stderr, twrap.fill(', '.join(items))
400        return 1
401
402
403    def __init__(self):
404        """Setup the parser common options"""
405        # Initialized for unit tests.
406        self.afe = None
407        self.failed = {}
408        self.data = {}
409        self.debug = False
410        self.parse_delim = '|'
411        self.kill_on_failure = False
412        self.web_server = ''
413        self.verbose = False
414        self.no_confirmation = False
415        # Whether the topic or command supports skylab inventory repo.
416        self.allow_skylab = False
417        self.enforce_skylab = False
418        self.topic_parse_info = item_parse_info(attribute_name='not_used')
419
420        self.parser = optparse.OptionParser(self._get_usage())
421        self.parser.add_option('-g', '--debug',
422                               help='Print debugging information',
423                               action='store_true', default=False)
424        self.parser.add_option('--kill-on-failure',
425                               help='Stop at the first failure',
426                               action='store_true', default=False)
427        self.parser.add_option('--parse',
428                               help='Print the output using | '
429                               'separated key=value fields',
430                               action='store_true', default=False)
431        self.parser.add_option('--parse-delim',
432                               help='Delimiter to use to separate the '
433                               'key=value fields', default='|')
434        self.parser.add_option('--no-confirmation',
435                               help=('Skip all confirmation in when function '
436                                     'require_confirmation is called.'),
437                               action='store_true', default=False)
438        self.parser.add_option('-v', '--verbose',
439                               action='store_true', default=False)
440        self.parser.add_option('-w', '--web',
441                               help='Specify the autotest server '
442                               'to talk to',
443                               action='store', type='string',
444                               dest='web_server', default=None)
445        self.parser.add_option('--log-level',
446                               help=('Set the logging level. Must be one of %s.'
447                                     ' Default to ERROR' %
448                                     LOGGING_LEVEL_MAP.keys()),
449                               choices=LOGGING_LEVEL_MAP.keys(),
450                               default='ERROR',
451                               dest='log_level')
452
453
454    def add_skylab_options(self, enforce_skylab=False):
455        """Add options for reading and writing skylab inventory repository."""
456        self.allow_skylab = True
457        self.enforce_skylab = enforce_skylab
458
459        self.parser.add_option('--skylab',
460                                help=('Use the skylab inventory as the data '
461                                      'source. Default to %s.' %
462                                       self.enforce_skylab),
463                                action='store_true', dest='skylab',
464                                default=self.enforce_skylab)
465        self.parser.add_option('--env',
466                               help=('Environment ("prod" or "staging") of the '
467                                     'machine. Default to "prod". %s' %
468                                     skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
469                               dest='environment',
470                               default='prod')
471        self.parser.add_option('--inventory-repo-dir',
472                               help=('The path of directory to clone skylab '
473                                     'inventory repo into. It can be an empty '
474                                     'folder or an existing clean checkout of '
475                                     'infra_internal/skylab_inventory. '
476                                     'If not provided, a temporary dir will be '
477                                     'created and used as the repo dir. %s' %
478                                     skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
479                               dest='inventory_repo_dir')
480        self.parser.add_option('--keep-repo-dir',
481                               help=('Keep the inventory-repo-dir after the '
482                                     'action completes, otherwise the dir will '
483                                     'be cleaned up. %s' %
484                                     skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
485                               action='store_true',
486                               dest='keep_repo_dir')
487        self.parser.add_option('--draft',
488                               help=('Upload a change CL as a draft. %s' %
489                                     skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
490                               action='store_true',
491                               dest='draft',
492                               default=False)
493        self.parser.add_option('--dryrun',
494                               help=('Execute the action as a dryrun. %s' %
495                                     skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
496                               action='store_true',
497                               dest='dryrun',
498                               default=False)
499        self.parser.add_option('--submit',
500                               help=('Submit a change CL directly without '
501                                     'reviewing and submitting it in Gerrit. %s'
502                                     % skylab_utils.MSG_ONLY_VALID_IN_SKYLAB),
503                               action='store_true',
504                               dest='submit',
505                               default=False)
506
507
508    def _get_usage(self):
509        return "atest %s %s [options] %s" % (self.msg_topic.lower(),
510                                             self.usage_action,
511                                             self.msg_items)
512
513
514    def backward_compatibility(self, action, argv):
515        """To be overidden by subclass if their syntax changed.
516
517        @param action: Name of the action.
518        @param argv: A list of arguments.
519        """
520        return action
521
522
523    def parse_skylab_options(self, options):
524        """Parse skylab related options.
525
526        @param: options: Option values parsed by the parser.
527        """
528        self.skylab = options.skylab
529        if not self.skylab:
530            return
531
532        # TODO(nxia): crbug.com/837831 Add skylab_inventory to
533        # autotest-server-deps ebuilds to remove the ImportError check.
534        if not skylab_inventory_imported:
535            raise skylab_utils.SkylabInventoryNotImported(
536                    "Please try to run utils/build_externals.py.")
537
538        self.draft = options.draft
539
540        self.dryrun = options.dryrun
541        if self.dryrun:
542            print('This is a dryrun. NO CL will be uploaded.\n')
543
544        self.submit = options.submit
545        if self.submit and (self.dryrun or self.draft):
546            self.invalid_syntax('Can not set --dryrun or --draft when '
547                                '--submit is set.')
548
549        # The change number of the inventory change CL.
550        self.change_number = None
551
552        self.environment = options.environment
553        translation_utils.validate_environment(self.environment)
554
555        self.keep_repo_dir = options.keep_repo_dir
556        self.inventory_repo_dir = options.inventory_repo_dir
557        if self.inventory_repo_dir is None:
558            self.temp_dir = autotemp.tempdir(
559                    prefix='inventory_repo',
560                    auto_clean=not self.keep_repo_dir)
561
562            self.inventory_repo_dir = self.temp_dir.name
563            if self.debug or self.keep_repo_dir:
564                print('The inventory_repo_dir is created at %s' %
565                      self.inventory_repo_dir)
566
567
568    def parse(self, parse_info=[], req_items=None):
569        """Parse command arguments.
570
571        parse_info is a list of item_parse_info objects.
572        There should only be one use_leftover set to True in the list.
573
574        Also check that the req_items is not empty after parsing.
575
576        @param parse_info: A list of item_parse_info objects.
577        @param req_items: A list of required items.
578        """
579        (options, leftover) = self.parse_global()
580
581        all_parse_info = parse_info[:]
582        all_parse_info.append(self.topic_parse_info)
583
584        try:
585            for item_parse_info in all_parse_info:
586                values, leftover = item_parse_info.get_values(options,
587                                                              leftover)
588                setattr(self, item_parse_info.attribute_name, values)
589        except CliError, s:
590            self.invalid_syntax(s)
591
592        if (req_items and not getattr(self, req_items, None)):
593            self.invalid_syntax('%s %s requires at least one %s' %
594                                (self.msg_topic,
595                                 self.usage_action,
596                                 self.msg_topic))
597
598        if self.allow_skylab:
599            self.parse_skylab_options(options)
600
601        logging.getLogger().setLevel(LOGGING_LEVEL_MAP[options.log_level])
602
603        return (options, leftover)
604
605
606    def parse_global(self):
607        """Parse the global arguments.
608
609        It consumes what the common object needs to know, and
610        let the children look at all the options.  We could
611        remove the options that we have used, but there is no
612        harm in leaving them, and the children may need them
613        in the future.
614
615        Must be called from its children parse()"""
616        (options, leftover) = self.parser.parse_args()
617        # Handle our own options setup in __init__()
618        self.debug = options.debug
619        self.kill_on_failure = options.kill_on_failure
620
621        if options.parse:
622            suffix = '_parse'
623        else:
624            suffix = '_std'
625        for func in ['print_fields', 'print_table',
626                     'print_by_ids', 'print_list']:
627            setattr(self, func, getattr(self, func + suffix))
628
629        self.parse_delim = options.parse_delim
630
631        self.verbose = options.verbose
632        self.no_confirmation = options.no_confirmation
633        self.web_server = options.web_server
634        try:
635            self.afe = rpc.afe_comm(self.web_server)
636        except rpc.AuthError, s:
637            self.failure(str(s), fatal=True)
638
639        return (options, leftover)
640
641
642    def check_and_create_items(self, op_get, op_create,
643                                items, **data_create):
644        """Create the items if they don't exist already.
645
646        @param op_get: Name of `get` RPC.
647        @param op_create: Name of `create` RPC.
648        @param items: Actionable items specified in CLI command, e.g., hostname,
649                      to be passed to each RPC.
650        @param data_create: Data to be passed to `create` RPC.
651        """
652        for item in items:
653            ret = self.execute_rpc(op_get, name=item)
654
655            if len(ret) == 0:
656                try:
657                    data_create['name'] = item
658                    self.execute_rpc(op_create, **data_create)
659                except CliError:
660                    continue
661
662
663    def execute_rpc(self, op, item='', **data):
664        """Execute RPC.
665
666        @param op: Name of the RPC.
667        @param item: Actionable item specified in CLI command.
668        @param data: Data to be passed to RPC.
669        """
670        retry = 2
671        while retry:
672            try:
673                return self.afe.run(op, **data)
674            except urllib2.URLError, err:
675                if hasattr(err, 'reason'):
676                    if 'timed out' not in err.reason:
677                        self.invalid_syntax('Invalid server name %s: %s' %
678                                            (self.afe.web_server, err))
679                if hasattr(err, 'code'):
680                    error_parts = [str(err)]
681                    if self.debug:
682                        error_parts.append(err.read()) # read the response body
683                    self.failure('\n\n'.join(error_parts), item=item,
684                                 what_failed=("Error received from web server"))
685                    raise CliError("Error from web server")
686                if self.debug:
687                    print 'retrying: %r %d' % (data, retry)
688                retry -= 1
689                if retry == 0:
690                    if item:
691                        myerr = '%s timed out for %s' % (op, item)
692                    else:
693                        myerr = '%s timed out' % op
694                    self.failure(myerr, item=item,
695                                 what_failed=("Timed-out contacting "
696                                              "the Autotest server"))
697                    raise CliError("Timed-out contacting the Autotest server")
698            except mock.CheckPlaybackError:
699                raise
700            except Exception, full_error:
701                # There are various exceptions throwns by JSON,
702                # urllib & httplib, so catch them all.
703                self.failure(full_error, item=item,
704                             what_failed='Operation %s failed' % op)
705                raise CliError(str(full_error))
706
707
708    # There is no output() method in the atest object (yet?)
709    # but here are some helper functions to be used by its
710    # children
711    def print_wrapped(self, msg, values):
712        """Print given message and values in wrapped lines unless
713        AUTOTEST_CLI_NO_WRAP is specified in environment variables.
714
715        @param msg: Message to print.
716        @param values: A list of values to print.
717        """
718        if len(values) == 0:
719            return
720        elif len(values) == 1:
721            print msg + ': '
722        elif len(values) > 1:
723            if msg.endswith('s'):
724                print msg + ': '
725            else:
726                print msg + 's: '
727
728        values.sort()
729
730        if 'AUTOTEST_CLI_NO_WRAP' in os.environ:
731            print '\n'.join(values)
732            return
733
734        twrap = textwrap.TextWrapper(initial_indent='\t',
735                                     subsequent_indent='\t')
736        print twrap.fill(', '.join(values))
737
738
739    def __conv_value(self, type, value):
740        return KEYS_CONVERT.get(type, str)(value)
741
742
743    def print_fields_std(self, items, keys, title=None):
744        """Print the keys in each item, one on each line.
745
746        @param items: Items to print.
747        @param keys: Name of the keys to look up each item in items.
748        @param title: Title of the output, default to None.
749        """
750        if not items:
751            return
752        if title:
753            print title
754        for item in items:
755            for key in keys:
756                print '%s: %s' % (KEYS_TO_NAMES_EN[key],
757                                  self.__conv_value(key,
758                                                    _get_item_key(item, key)))
759
760
761    def print_fields_parse(self, items, keys, title=None):
762        """Print the keys in each item as comma separated name=value
763
764        @param items: Items to print.
765        @param keys: Name of the keys to look up each item in items.
766        @param title: Title of the output, default to None.
767        """
768        for item in items:
769            values = ['%s=%s' % (KEYS_TO_NAMES_EN[key],
770                                  self.__conv_value(key,
771                                                    _get_item_key(item, key)))
772                      for key in keys
773                      if self.__conv_value(key,
774                                           _get_item_key(item, key)) != '']
775            print self.parse_delim.join(values)
776
777
778    def __find_justified_fmt(self, items, keys):
779        """Find the max length for each field.
780
781        @param items: Items to lookup for.
782        @param keys: Name of the keys to look up each item in items.
783        """
784        lens = {}
785        # Don't justify the last field, otherwise we have blank
786        # lines when the max is overlaps but the current values
787        # are smaller
788        if not items:
789            print "No results"
790            return
791        for key in keys[:-1]:
792            lens[key] = max(len(self.__conv_value(key,
793                                                  _get_item_key(item, key)))
794                            for item in items)
795            lens[key] = max(lens[key], len(KEYS_TO_NAMES_EN[key]))
796        lens[keys[-1]] = 0
797
798        return '  '.join(["%%-%ds" % lens[key] for key in keys])
799
800
801    def print_dict(self, items, title=None, line_before=False):
802        """Print a dictionary.
803
804        @param items: Dictionary to print.
805        @param title: Title of the output, default to None.
806        @param line_before: True to print an empty line before the output,
807                            default to False.
808        """
809        if not items:
810            return
811        if line_before:
812            print
813        print title
814        for key, value in items.items():
815            print '%s : %s' % (key, value)
816
817
818    def print_table_std(self, items, keys_header, sublist_keys=()):
819        """Print a mix of header and lists in a user readable format.
820
821        The headers are justified, the sublist_keys are wrapped.
822
823        @param items: Items to print.
824        @param keys_header: Header of the keys, use to look up in items.
825        @param sublist_keys: Keys for sublist in each item.
826        """
827        if not items:
828            return
829        fmt = self.__find_justified_fmt(items, keys_header)
830        header = tuple(KEYS_TO_NAMES_EN[key] for key in keys_header)
831        print fmt % header
832        for item in items:
833            values = tuple(self.__conv_value(key,
834                                             _get_item_key(item, key))
835                           for key in keys_header)
836            print fmt % values
837            if sublist_keys:
838                for key in sublist_keys:
839                    self.print_wrapped(KEYS_TO_NAMES_EN[key],
840                                       _get_item_key(item, key))
841                print '\n'
842
843
844    def print_table_parse(self, items, keys_header, sublist_keys=()):
845        """Print a mix of header and lists in a user readable format.
846
847        @param items: Items to print.
848        @param keys_header: Header of the keys, use to look up in items.
849        @param sublist_keys: Keys for sublist in each item.
850        """
851        for item in items:
852            values = ['%s=%s' % (KEYS_TO_NAMES_EN[key],
853                                 self.__conv_value(key, _get_item_key(item, key)))
854                      for key in keys_header
855                      if self.__conv_value(key,
856                                           _get_item_key(item, key)) != '']
857
858            if sublist_keys:
859                [values.append('%s=%s'% (KEYS_TO_NAMES_EN[key],
860                                         ','.join(_get_item_key(item, key))))
861                 for key in sublist_keys
862                 if len(_get_item_key(item, key))]
863
864            print self.parse_delim.join(values)
865
866
867    def print_by_ids_std(self, items, title=None, line_before=False):
868        """Prints ID & names of items in a user readable form.
869
870        @param items: Items to print.
871        @param title: Title of the output, default to None.
872        @param line_before: True to print an empty line before the output,
873                            default to False.
874        """
875        if not items:
876            return
877        if line_before:
878            print
879        if title:
880            print title + ':'
881        self.print_table_std(items, keys_header=['id', 'name'])
882
883
884    def print_by_ids_parse(self, items, title=None, line_before=False):
885        """Prints ID & names of items in a parseable format.
886
887        @param items: Items to print.
888        @param title: Title of the output, default to None.
889        @param line_before: True to print an empty line before the output,
890                            default to False.
891        """
892        if not items:
893            return
894        if line_before:
895            print
896        if title:
897            print title + '=',
898        values = []
899        for item in items:
900            values += ['%s=%s' % (KEYS_TO_NAMES_EN[key],
901                                  self.__conv_value(key,
902                                                    _get_item_key(item, key)))
903                       for key in ['id', 'name']
904                       if self.__conv_value(key,
905                                            _get_item_key(item, key)) != '']
906        print self.parse_delim.join(values)
907
908
909    def print_list_std(self, items, key):
910        """Print a wrapped list of results
911
912        @param items: Items to to lookup for given key, could be a nested
913                      dictionary.
914        @param key: Name of the key to look up for value.
915        """
916        if not items:
917            return
918        print ' '.join(_get_item_key(item, key) for item in items)
919
920
921    def print_list_parse(self, items, key):
922        """Print a wrapped list of results.
923
924        @param items: Items to to lookup for given key, could be a nested
925                      dictionary.
926        @param key: Name of the key to look up for value.
927        """
928        if not items:
929            return
930        print '%s=%s' % (KEYS_TO_NAMES_EN[key],
931                         ','.join(_get_item_key(item, key) for item in items))
932
933
934    @staticmethod
935    def prompt_confirmation(message=None):
936        """Prompt a question for user to confirm the action before proceeding.
937
938        @param message: A detailed message to explain possible impact of the
939                        action.
940
941        @return: True to proceed or False to abort.
942        """
943        if message:
944            print message
945        sys.stdout.write('Continue? [y/N] ')
946        read = raw_input().lower()
947        if read == 'y':
948            return True
949        else:
950            print 'User did not confirm. Aborting...'
951            return False
952
953
954    @staticmethod
955    def require_confirmation(message=None):
956        """Decorator to prompt a question for user to confirm action before
957        proceeding.
958
959        If user chooses not to proceed, do not call the function.
960
961        @param message: A detailed message to explain possible impact of the
962                        action.
963
964        @return: A decorator wrapper for calling the actual function.
965        """
966        def deco_require_confirmation(func):
967            """Wrapper for the decorator.
968
969            @param func: Function to be called.
970
971            @return: the actual decorator to call the function.
972            """
973            def func_require_confirmation(*args, **kwargs):
974                """Decorator to prompt a question for user to confirm.
975
976                @param message: A detailed message to explain possible impact of
977                                the action.
978                """
979                if (args[0].no_confirmation or
980                    atest.prompt_confirmation(message)):
981                    func(*args, **kwargs)
982
983            return func_require_confirmation
984        return deco_require_confirmation
985