1 # 2 # Copyright 2008 Google Inc. All Rights Reserved. 3 # 4 """ 5 This module contains the generic CLI object 6 7 High Level Design: 8 9 The atest class contains attributes & method generic to all the CLI 10 operations. 11 12 The class inheritance is shown here using the command 13 'atest host create ...' as an example: 14 15 atest <-- host <-- host_create <-- site_host_create 16 17 Note: The site_<topic>.py and its classes are only needed if you need 18 to override the common <topic>.py methods with your site specific ones. 19 20 21 High Level Algorithm: 22 23 1. 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 27 1. 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 35 2. 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 47 3. 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 52 4. 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 58 import logging 59 import optparse 60 import os 61 import re 62 import sys 63 import textwrap 64 import traceback 65 import urllib2 66 67 import common 68 69 from autotest_lib.cli import rpc 70 from autotest_lib.cli import skylab_utils 71 from autotest_lib.client.common_lib.test_utils import mock 72 from autotest_lib.client.common_lib import autotemp 73 74 skylab_inventory_imported = False 75 try: 76 from skylab_inventory import translation_utils 77 skylab_inventory_imported = True 78 except ImportError: 79 pass 80 81 82 # Maps the AFE keys to printable names. 83 KEYS_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. 126 FAIL_TAG = '<XYZ>' 127 128 # Global socket timeout: uploading kernels can take much, 129 # much longer than the default 130 UPLOAD_SOCKET_TIMEOUT = 60*30 131 132 LOGGING_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. 143 def __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 154 def _int_2_bool_string(value): 155 return str(bool(value)) 156 157 KEYS_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 165 def _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 182 class CliError(Exception): 183 """Error raised by cli calls. 184 """ 185 pass 186 187 188 class 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 270 class 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