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