2 # Copyright 2008 Google Inc. All Rights Reserved.
5 This module contains the generic CLI object
9 The atest class contains attributes & method generic to all the CLI
12 The class inheritance is shown here using the command
13 'atest host create ...' as an example:
15 atest <-- host <-- host_create <-- site_host_create
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.
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>)
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>.
32 If it exists, the child __init__() method must call its parent
33 class __init__() before adding its own parser arguments.
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()
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
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.
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.
58 import getpass
, optparse
, os
, pwd
, re
, socket
, sys
, textwrap
, traceback
59 import socket
, string
, urllib2
60 from autotest_lib
.cli
import rpc
61 from autotest_lib
.frontend
.afe
.json_rpc
import proxy
62 from autotest_lib
.client
.common_lib
.test_utils
import mock
65 # Maps the AFE keys to printable names.
66 KEYS_TO_NAMES_EN
= {'hostname': 'Host',
67 'platform': 'Platform',
70 'locked_by': 'Locked by',
71 'lock_time': 'Locked time',
73 'description': 'Description',
80 'access_level': 'Access Level',
82 'job_owner': 'Job Owner',
83 'job_name': 'Job Name',
84 'test_type': 'Test Type',
85 'test_class': 'Test Class',
88 'status_counts': 'Status Counts',
89 'hosts_status': 'Host Status',
90 'hosts_selected_status': 'Hosts filtered by Status',
91 'priority': 'Priority',
92 'control_type': 'Control Type',
93 'created_on': 'Created On',
94 'synch_type': 'Synch Type',
95 'control_file': 'Control File',
96 'only_if_needed': 'Use only if needed',
97 'protection': 'Protection',
98 'run_verify': 'Run verify',
99 'reboot_before': 'Pre-job reboot',
100 'reboot_after': 'Post-job reboot',
101 'experimental': 'Experimental',
102 'synch_count': 'Sync Count',
103 'max_number_of_machines': 'Max. hosts to use',
104 'parse_failed_repair': 'Include failed repair results',
105 'atomic_group.name': 'Atomic Group Name',
108 # In the failure, tag that will replace the item.
111 # Global socket timeout: uploading kernels can take much,
112 # much longer than the default
113 UPLOAD_SOCKET_TIMEOUT
= 60*30
116 # Convertion functions to be called for printing,
117 # e.g. to print True/False for booleans.
118 def __convert_platform(field
):
121 elif isinstance(field
, int):
122 # Can be 0/1 for False/True
123 return str(bool(field
))
125 # Can be a platform name
129 def _int_2_bool_string(value
):
130 return str(bool(value
))
132 KEYS_CONVERT
= {'locked': _int_2_bool_string
,
133 'invalid': lambda flag
: str(bool(not flag
)),
134 'only_if_needed': _int_2_bool_string
,
135 'platform': __convert_platform
,
136 'labels': lambda labels
: ', '.join(labels
)}
139 def _get_item_key(item
, key
):
140 """Allow for lookups in nested dictionaries using '.'s within a key."""
144 for subkey
in key
.split('.'):
146 raise ValueError('empty subkey in %r' % key
)
148 nested_item
= nested_item
[subkey
]
150 raise KeyError('%r - looking up key %r in %r' %
151 (e
, key
, nested_item
))
156 class CliError(Exception):
160 class item_parse_info(object):
161 def __init__(self
, attribute_name
, inline_option
='',
162 filename_option
='', use_leftover
=False):
163 """Object keeping track of the parsing options that will
164 make up the content of the atest attribute:
165 atttribute_name: the atest attribute name to populate (label)
166 inline_option: the option containing the items (--label)
167 filename_option: the option containing the filename (--blist)
168 use_leftover: whether to add the leftover arguments or not."""
169 self
.attribute_name
= attribute_name
170 self
.filename_option
= filename_option
171 self
.inline_option
= inline_option
172 self
.use_leftover
= use_leftover
175 def get_values(self
, options
, leftover
=[]):
176 """Returns the value for that attribute by accumualting all
177 the values found through the inline option, the parsing of the
178 file and the leftover"""
180 def __get_items(input, split_spaces
=True):
181 """Splits a string of comma separated items. Escaped commas will not
182 be split. I.e. Splitting 'a, b\,c, d' will yield ['a', 'b,c', 'd'].
183 If split_spaces is set to False spaces will not be split. I.e.
184 Splitting 'a b, c\,d, e' will yield ['a b', 'c,d', 'e']"""
186 # Replace escaped slashes with null characters so we don't misparse
188 input = input.replace(r
'\\', '\0')
190 # Split on commas which are not preceded by a slash.
192 split
= re
.split(r
'(?<!\\),', input)
194 split
= re
.split(r
'(?<!\\),|\s', input)
196 # Convert null characters to single slashes and escaped commas to
198 return (item
.strip().replace('\0', '\\').replace(r
'\,', ',') for
199 item
in split
if item
.strip())
201 if self
.use_leftover
:
207 # Start with the add_on
210 # Don't split on space here because the add-on
211 # may have some spaces (like the job name)
212 result
.update(__get_items(items
, split_spaces
=False))
214 # Process the inline_option, if any
216 items
= getattr(options
, self
.inline_option
)
217 result
.update(__get_items(items
))
218 except (AttributeError, TypeError):
221 # Process the file list, if any and not empty
222 # The file can contain space and/or comma separated items
224 flist
= getattr(options
, self
.filename_option
)
226 for line
in open(flist
).readlines():
227 file_content
+= __get_items(line
)
228 if len(file_content
) == 0:
229 raise CliError("Empty file %s" % flist
)
230 result
.update(file_content
)
231 except (AttributeError, TypeError):
234 raise CliError("Could not open file %s" % flist
)
236 return list(result
), leftover
240 """Common class for generic processing
241 Should only be instantiated by itself for usage
242 references, otherwise, the <topic> objects should
244 msg_topic
= "[acl|host|job|label|atomicgroup|test|user]"
245 usage_action
= "[action]"
248 def invalid_arg(self
, header
, follow_up
=''):
249 twrap
= textwrap
.TextWrapper(initial_indent
=' ',
250 subsequent_indent
=' ')
251 rest
= twrap
.fill(follow_up
)
253 if self
.kill_on_failure
:
254 self
.invalid_syntax(header
+ rest
)
256 print >> sys
.stderr
, header
+ rest
259 def invalid_syntax(self
, msg
):
261 print >> sys
.stderr
, msg
264 print self
._get
_usage
()
269 def generic_error(self
, msg
):
271 traceback
.print_exc()
272 print >> sys
.stderr
, msg
276 def parse_json_exception(self
, full_error
):
277 """Parses the JSON exception to extract the bad
278 items and returns them
279 This is very kludgy for the moment, but we would need
280 to refactor the exceptions sent from the front end
281 to make this better"""
282 errmsg
= str(full_error
).split('Traceback')[0].rstrip('\n')
283 parts
= errmsg
.split(':')
284 # Kludge: If there are 2 colons the last parts contains
285 # the items that failed.
288 return [item
.strip() for item
in parts
[2].split(',') if item
.strip()]
291 def failure(self
, full_error
, item
=None, what_failed
='', fatal
=False):
292 """If kill_on_failure, print this error and die,
293 otherwise, queue the error and accumulate all the items
294 that triggered the same error."""
297 errmsg
= str(full_error
)
299 errmsg
= str(full_error
).split('Traceback')[0].rstrip('\n')
301 if self
.kill_on_failure
or fatal
:
302 print >> sys
.stderr
, "%s\n %s" % (what_failed
, errmsg
)
305 # Build a dictionary with the 'what_failed' as keys. The
306 # values are dictionaries with the errmsg as keys and a set
307 # of items as values.
309 # {'Operation delete_host_failed': {'AclAccessViolation:
310 # set('host0', 'host1')}}
311 # Try to gather all the same error messages together,
312 # even if they contain the 'item'
313 if item
and item
in errmsg
:
314 errmsg
= errmsg
.replace(item
, FAIL_TAG
)
315 if self
.failed
.has_key(what_failed
):
316 self
.failed
[what_failed
].setdefault(errmsg
, set()).add(item
)
318 self
.failed
[what_failed
] = {errmsg
: set([item
])}
321 def show_all_failures(self
):
324 for what_failed
in self
.failed
.keys():
325 print >> sys
.stderr
, what_failed
+ ':'
326 for (errmsg
, items
) in self
.failed
[what_failed
].iteritems():
328 print >> sys
.stderr
, errmsg
329 elif items
== set(['']):
330 print >> sys
.stderr
, ' ' + errmsg
331 elif len(items
) == 1:
332 # Restore the only item
333 if FAIL_TAG
in errmsg
:
334 errmsg
= errmsg
.replace(FAIL_TAG
, items
.pop())
336 errmsg
= '%s (%s)' % (errmsg
, items
.pop())
337 print >> sys
.stderr
, ' ' + errmsg
339 print >> sys
.stderr
, ' ' + errmsg
+ ' with <XYZ> in:'
340 twrap
= textwrap
.TextWrapper(initial_indent
=' ',
341 subsequent_indent
=' ')
344 print >> sys
.stderr
, twrap
.fill(', '.join(items
))
349 """Setup the parser common options"""
350 # Initialized for unit tests.
355 self
.parse_delim
= '|'
356 self
.kill_on_failure
= False
359 self
.topic_parse_info
= item_parse_info(attribute_name
='not_used')
361 self
.parser
= optparse
.OptionParser(self
._get
_usage
())
362 self
.parser
.add_option('-g', '--debug',
363 help='Print debugging information',
364 action
='store_true', default
=False)
365 self
.parser
.add_option('--kill-on-failure',
366 help='Stop at the first failure',
367 action
='store_true', default
=False)
368 self
.parser
.add_option('--parse',
369 help='Print the output using | '
370 'separated key=value fields',
371 action
='store_true', default
=False)
372 self
.parser
.add_option('--parse-delim',
373 help='Delimiter to use to separate the '
374 'key=value fields', default
='|')
375 self
.parser
.add_option('-v', '--verbose',
376 action
='store_true', default
=False)
377 self
.parser
.add_option('-w', '--web',
378 help='Specify the autotest server '
380 action
='store', type='string',
381 dest
='web_server', default
=None)
384 def _get_usage(self
):
385 return "atest %s %s [options] %s" % (self
.msg_topic
.lower(),
390 def backward_compatibility(self
, action
, argv
):
391 """To be overidden by subclass if their syntax changed"""
395 def parse(self
, parse_info
=[], req_items
=None):
396 """parse_info is a list of item_parse_info objects
398 There should only be one use_leftover set to True in the list.
400 Also check that the req_items is not empty after parsing."""
401 (options
, leftover
) = self
.parse_global()
403 all_parse_info
= parse_info
[:]
404 all_parse_info
.append(self
.topic_parse_info
)
407 for item_parse_info
in all_parse_info
:
408 values
, leftover
= item_parse_info
.get_values(options
,
410 setattr(self
, item_parse_info
.attribute_name
, values
)
412 self
.invalid_syntax(s
)
414 if (req_items
and not getattr(self
, req_items
, None)):
415 self
.invalid_syntax('%s %s requires at least one %s' %
420 return (options
, leftover
)
423 def parse_global(self
):
424 """Parse the global arguments.
426 It consumes what the common object needs to know, and
427 let the children look at all the options. We could
428 remove the options that we have used, but there is no
429 harm in leaving them, and the children may need them
432 Must be called from its children parse()"""
433 (options
, leftover
) = self
.parser
.parse_args()
434 # Handle our own options setup in __init__()
435 self
.debug
= options
.debug
436 self
.kill_on_failure
= options
.kill_on_failure
442 for func
in ['print_fields', 'print_table',
443 'print_by_ids', 'print_list']:
444 setattr(self
, func
, getattr(self
, func
+ suffix
))
446 self
.parse_delim
= options
.parse_delim
448 self
.verbose
= options
.verbose
449 self
.web_server
= options
.web_server
451 self
.afe
= rpc
.afe_comm(self
.web_server
)
452 except rpc
.AuthError
, s
:
453 self
.failure(str(s
), fatal
=True)
455 return (options
, leftover
)
458 def check_and_create_items(self
, op_get
, op_create
,
459 items
, **data_create
):
460 """Create the items if they don't exist already"""
462 ret
= self
.execute_rpc(op_get
, name
=item
)
466 data_create
['name'] = item
467 self
.execute_rpc(op_create
, **data_create
)
472 def execute_rpc(self
, op
, item
='', **data
):
476 return self
.afe
.run(op
, **data
)
477 except urllib2
.URLError
, err
:
478 if hasattr(err
, 'reason'):
479 if 'timed out' not in err
.reason
:
480 self
.invalid_syntax('Invalid server name %s: %s' %
481 (self
.afe
.web_server
, err
))
482 if hasattr(err
, 'code'):
483 error_parts
= [str(err
)]
485 error_parts
.append(err
.read()) # read the response body
486 self
.failure('\n\n'.join(error_parts
), item
=item
,
487 what_failed
=("Error received from web server"))
488 raise CliError("Error from web server")
490 print 'retrying: %r %d' % (data
, retry
)
494 myerr
= '%s timed out for %s' % (op
, item
)
496 myerr
= '%s timed out' % op
497 self
.failure(myerr
, item
=item
,
498 what_failed
=("Timed-out contacting "
499 "the Autotest server"))
500 raise CliError("Timed-out contacting the Autotest server")
501 except mock
.CheckPlaybackError
:
503 except Exception, full_error
:
504 # There are various exceptions throwns by JSON,
505 # urllib & httplib, so catch them all.
506 self
.failure(full_error
, item
=item
,
507 what_failed
='Operation %s failed' % op
)
508 raise CliError(str(full_error
))
511 # There is no output() method in the atest object (yet?)
512 # but here are some helper functions to be used by its
514 def print_wrapped(self
, msg
, values
):
517 elif len(values
) == 1:
519 elif len(values
) > 1:
520 if msg
.endswith('s'):
527 if 'AUTOTEST_CLI_NO_WRAP' in os
.environ
:
528 print '\n'.join(values
)
531 twrap
= textwrap
.TextWrapper(initial_indent
='\t',
532 subsequent_indent
='\t')
533 print twrap
.fill(', '.join(values
))
536 def __conv_value(self
, type, value
):
537 return KEYS_CONVERT
.get(type, str)(value
)
540 def print_fields_std(self
, items
, keys
, title
=None):
541 """Print the keys in each item, one on each line"""
548 print '%s: %s' % (KEYS_TO_NAMES_EN
[key
],
549 self
.__conv
_value
(key
,
550 _get_item_key(item
, key
)))
553 def print_fields_parse(self
, items
, keys
, title
=None):
554 """Print the keys in each item as comma
555 separated name=value"""
557 values
= ['%s=%s' % (KEYS_TO_NAMES_EN
[key
],
558 self
.__conv
_value
(key
,
559 _get_item_key(item
, key
)))
561 if self
.__conv
_value
(key
,
562 _get_item_key(item
, key
)) != '']
563 print self
.parse_delim
.join(values
)
566 def __find_justified_fmt(self
, items
, keys
):
567 """Find the max length for each field."""
569 # Don't justify the last field, otherwise we have blank
570 # lines when the max is overlaps but the current values
575 for key
in keys
[:-1]:
576 lens
[key
] = max(len(self
.__conv
_value
(key
,
577 _get_item_key(item
, key
)))
579 lens
[key
] = max(lens
[key
], len(KEYS_TO_NAMES_EN
[key
]))
582 return ' '.join(["%%-%ds" % lens
[key
] for key
in keys
])
585 def print_table_std(self
, items
, keys_header
, sublist_keys
=()):
586 """Print a mix of header and lists in a user readable
588 The headers are justified, the sublist_keys are wrapped."""
591 fmt
= self
.__find
_justified
_fmt
(items
, keys_header
)
592 header
= tuple(KEYS_TO_NAMES_EN
[key
] for key
in keys_header
)
595 values
= tuple(self
.__conv
_value
(key
,
596 _get_item_key(item
, key
))
597 for key
in keys_header
)
600 for key
in sublist_keys
:
601 self
.print_wrapped(KEYS_TO_NAMES_EN
[key
],
602 _get_item_key(item
, key
))
606 def print_table_parse(self
, items
, keys_header
, sublist_keys
=()):
607 """Print a mix of header and lists in a user readable
610 values
= ['%s=%s' % (KEYS_TO_NAMES_EN
[key
],
611 self
.__conv
_value
(key
, _get_item_key(item
, key
)))
612 for key
in keys_header
613 if self
.__conv
_value
(key
,
614 _get_item_key(item
, key
)) != '']
617 [values
.append('%s=%s'% (KEYS_TO_NAMES_EN
[key
],
618 ','.join(_get_item_key(item
, key
))))
619 for key
in sublist_keys
620 if len(_get_item_key(item
, key
))]
622 print self
.parse_delim
.join(values
)
625 def print_by_ids_std(self
, items
, title
=None, line_before
=False):
626 """Prints ID & names of items in a user readable form"""
633 self
.print_table_std(items
, keys_header
=['id', 'name'])
636 def print_by_ids_parse(self
, items
, title
=None, line_before
=False):
637 """Prints ID & names of items in a parseable format"""
644 values
+= ['%s=%s' % (KEYS_TO_NAMES_EN
[key
],
645 self
.__conv
_value
(key
,
646 _get_item_key(item
, key
)))
647 for key
in ['id', 'name']
648 if self
.__conv
_value
(key
,
649 _get_item_key(item
, key
)) != '']
650 print self
.parse_delim
.join(values
)
653 def print_list_std(self
, items
, key
):
654 """Print a wrapped list of results"""
657 print ' '.join(_get_item_key(item
, key
) for item
in items
)
660 def print_list_parse(self
, items
, key
):
661 """Print a wrapped list of results"""
664 print '%s=%s' % (KEYS_TO_NAMES_EN
[key
],
665 ','.join(_get_item_key(item
, key
) for item
in items
))