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