1# Copyright 2018 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import json 6import logging 7import os 8 9import dateutil.parser 10 11from autotest_lib.client.common_lib import base_job 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.common_lib.cros import dev_server 14from autotest_lib.client.common_lib.cros import tpm_utils 15from autotest_lib.server import test 16from autotest_lib.server import utils 17from autotest_lib.server.cros.network import wifi_test_context_manager 18from autotest_lib.server.hosts import cros_host 19from autotest_lib.server.hosts import servo_host 20from autotest_lib.server.hosts import servo_constants 21from autotest_lib.utils import labellib 22 23 24# A datetime.DateTime representing the Unix epoch in UTC. 25_UNIX_EPOCH = dateutil.parser.parse('1970-01-01T00:00:00Z') 26 27 28def _encode_utf8_json(j): 29 """Takes JSON object parsed by json.load() family, and encode each unicode 30 strings into utf-8. 31 """ 32 if isinstance(j, unicode): 33 return j.encode('utf-8') 34 if isinstance(j, list): 35 return [_encode_utf8_json(x) for x in j] 36 if isinstance(j, dict): 37 return dict((_encode_utf8_json(k), _encode_utf8_json(v)) 38 for k, v in j.iteritems()) 39 return j 40 41 42class tast(test.test): 43 """Autotest server test that runs a Tast test suite. 44 45 Tast is an integration-testing framework analagous to the test-running 46 portion of Autotest. See 47 https://chromium.googlesource.com/chromiumos/platform/tast/ for more 48 information. 49 50 This class runs the "tast" command locally to execute a Tast test suite on a 51 remote DUT. 52 """ 53 version = 1 54 55 # Maximum time to wait for various tast commands to complete, in seconds. 56 # Note that _LIST_TIMEOUT_SEC includes time to download private test bundles 57 # if run_private_tests=True. 58 _VERSION_TIMEOUT_SEC = 10 59 _LIST_TIMEOUT_SEC = 60 60 61 # Additional time to add to the run timeout (e.g. for collecting crashes and 62 # logs). 63 _RUN_OVERHEAD_SEC = 20 64 # Additional time given to the run command to exit before it's killed. 65 _RUN_EXIT_SEC = 5 66 67 # Number of times to retry SSH connection attempts to the DUT. 68 _SSH_CONNECT_RETRIES = 2 69 70 # File written by the tast command containing test results, as 71 # newline-terminated JSON TestResult objects. 72 _STREAMED_RESULTS_FILENAME = 'streamed_results.jsonl' 73 74 # Text file written by the tast command if a global error caused the test 75 # run to fail (e.g. SSH connection to DUT was lost). 76 _RUN_ERROR_FILENAME = 'run_error.txt' 77 78 # Maximum number of failing and missing tests to include in error messages. 79 _MAX_TEST_NAMES_IN_ERROR = 3 80 81 # Default paths where Tast files are installed by Portage packages. 82 _PORTAGE_TAST_PATH = '/usr/bin/tast' 83 84 # Alternate locations for Tast files when using Server-Side Packaging. 85 # These files are installed from autotest_server_package.tar.bz2. 86 _SSP_ROOT = '/usr/local/tast' 87 _SSP_TAST_PATH = os.path.join(_SSP_ROOT, 'tast') 88 _SSP_REMOTE_BUNDLE_DIR = os.path.join(_SSP_ROOT, 'bundles/remote') 89 _SSP_REMOTE_DATA_DIR = os.path.join(_SSP_ROOT, 'data') 90 _SSP_REMOTE_TEST_RUNNER_PATH = os.path.join(_SSP_ROOT, 'remote_test_runner') 91 _SSP_DEFAULT_VARS_DIR_PATH = os.path.join(_SSP_ROOT, 'vars') 92 93 # Prefix added to Tast test names when writing their results to TKO 94 # status.log files. 95 _TEST_NAME_PREFIX = 'tast.' 96 97 # Prefixes of keyval keys recorded for missing tests. 98 _MISSING_TEST_KEYVAL_PREFIX = 'tast_missing_test.' 99 100 # Job start/end TKO event status codes from base_client_job._rungroup in 101 # client/bin/job.py. 102 _JOB_STATUS_START = 'START' 103 _JOB_STATUS_END_GOOD = 'END GOOD' 104 _JOB_STATUS_END_FAIL = 'END FAIL' 105 _JOB_STATUS_END_ABORT = 'END ABORT' 106 107 # In-job TKO event status codes from base_client_job._run_test_base in 108 # client/bin/job.py and client/common_lib/error.py. 109 _JOB_STATUS_GOOD = 'GOOD' 110 _JOB_STATUS_FAIL = 'FAIL' 111 112 # Status reason used when an individual Tast test doesn't finish running. 113 _TEST_DID_NOT_FINISH_MSG = 'Test did not finish' 114 115 def initialize(self, 116 host, 117 test_exprs, 118 ignore_test_failures=False, 119 max_run_sec=3600, 120 command_args=[], 121 install_root='/', 122 ssp=None, 123 build=None, 124 build_bundle='cros', 125 run_private_tests=True, 126 varsfiles=[], 127 download_data_lazily=True, 128 clear_tpm=False, 129 varslist=[]): 130 """ 131 @param host: remote.RemoteHost instance representing DUT. 132 @param test_exprs: Array of strings describing tests to run. 133 @param ignore_test_failures: If False, this test will fail if individual 134 Tast tests report failure. If True, this test will only fail in 135 response to the tast command failing to run successfully. This 136 should generally be False when the test is running inline and True 137 when it's running asynchronously. 138 @param max_run_sec: Integer maximum running time for the "tast run" 139 command in seconds. 140 @param command_args: List of arguments passed on the command line via 141 test_that's --args flag, i.e. |args| in control file. 142 @param install_root: Root directory under which Tast binaries are 143 installed. Alternate values may be passed by unit tests. 144 @param ssp: Whether to use SSP files. Default is to auto-detect. 145 @param build: Whether to build test runners and test bundles. 146 Default is to build if and only if SSP is unavailable 147 (i.e. build = not ssp). 148 @param build_bundle: Test bundle name to build. Effective only when 149 build=True. 150 @param run_private_tests: Download and run private tests. Effective 151 only when build=False. When build=True, build_bundle can be 152 specified to build and run a private bundle. 153 @param varsfiles: list of names of yaml files containing variables set 154 in |-varsfile| arguments. 155 @param download_data_lazily: If True, external data files are downloaded 156 lazily between tests. If false, external data files are downloaded 157 in a batch before running tests. 158 @param clear_tpm: clear the TPM first before running the tast tests. 159 @param varslist: list of strings to pass to tast run command as |-vars| 160 arguments. Each string should be formatted as "name=value". 161 162 @raises error.TestFail if the Tast installation couldn't be found. 163 """ 164 if ssp is None: 165 ssp = os.path.exists(self._SSP_TAST_PATH) 166 if build is None: 167 build = not ssp 168 169 self._host = host 170 self._test_exprs = test_exprs 171 self._ignore_test_failures = ignore_test_failures 172 self._max_run_sec = max_run_sec 173 self._command_args = command_args 174 self._install_root = install_root 175 self._ssp = ssp 176 self._build = build 177 self._build_bundle = build_bundle 178 self._run_private_tests = run_private_tests 179 self._fake_now = None 180 self._varsfiles = varsfiles 181 self._varslist = varslist 182 self._download_data_lazily = download_data_lazily 183 self._clear_tpm = clear_tpm 184 185 # List of JSON objects describing tests that will be run. See Test in 186 # src/platform/tast/src/chromiumos/tast/testing/test.go for details. 187 self._tests_to_run = [] 188 189 # List of JSON objects corresponding to tests from 190 # _STREAMED_RESULTS_FILENAME. See TestResult in 191 # src/platform/tast/src/chromiumos/cmd/tast/run/results.go for details. 192 self._test_results = [] 193 194 # Error message read from _RUN_ERROR_FILENAME, if any. 195 self._run_error = None 196 197 self._tast_path = self._get_path( 198 self._SSP_TAST_PATH if ssp else self._PORTAGE_TAST_PATH) 199 200 # Register a hook to write the results of individual Tast tests as 201 # top-level entries in the TKO status.log file. 202 self.job.add_post_run_hook(self._log_all_tests) 203 204 def run_once(self): 205 """Runs a single iteration of the test.""" 206 207 if self._clear_tpm: 208 tpm_utils.ClearTPMOwnerRequest(self._host, wait_for_ready=True) 209 210 self._log_version() 211 self._find_devservers() 212 213 # Shortcut if no test belongs to the specified test_exprs. 214 if not self._get_tests_to_run(): 215 return 216 217 run_failed = False 218 try: 219 self._run_tests() 220 except: 221 run_failed = True 222 raise 223 finally: 224 self._read_run_error() 225 # Parse partial results even if the tast command didn't finish. 226 self._parse_results(run_failed) 227 228 def set_fake_now_for_testing(self, now): 229 """Sets a fake timestamp to use in place of time.time() for unit tests. 230 231 @param now Numeric timestamp as would be returned by time.time(). 232 """ 233 self._fake_now = now 234 235 def _get_path(self, path): 236 """Returns the path to an installed Tast-related file or directory. 237 238 @param path: Absolute paths in root filesystem, e.g. "/usr/bin/tast". 239 240 @returns Absolute path within install root, e.g. 241 "/usr/local/tast/usr/bin/tast". 242 """ 243 return os.path.join(self._install_root, os.path.relpath(path, '/')) 244 245 def _get_servo_args(self): 246 """Gets servo-related arguments to pass to "tast run". 247 248 @returns List of command-line flag strings that should be inserted in 249 the command line after "tast run". 250 """ 251 # Start with information provided by the Autotest database. 252 merged_args = {} 253 host_args = servo_host.get_servo_args_for_host(self._host) 254 if host_args: 255 merged_args.update(host_args) 256 257 # Incorporate information that was passed manually. 258 args_dict = utils.args_to_dict(self._command_args) 259 merged_args.update(cros_host.CrosHost.get_servo_arguments(args_dict)) 260 261 logging.info('Autotest servo-related args: %s', merged_args) 262 host_arg = merged_args.get(servo_constants.SERVO_HOST_ATTR) 263 port_arg = merged_args.get(servo_constants.SERVO_PORT_ATTR) 264 if not host_arg or not port_arg: 265 return [] 266 return ['-var=servo=%s:%s' % (host_arg, port_arg)] 267 268 def _get_wificell_args(self): 269 """Gets wificell-related (router, pcap) arguments to pass to "tast run". 270 271 @returns List of command-line flag strings that should be inserted in 272 the command line after "tast run". 273 """ 274 # Incorporate information that was passed manually. 275 args_dict = utils.args_to_dict(self._command_args) 276 args = [] 277 # Alias of WiFiTestContextManager. 278 WiFiManager = wifi_test_context_manager.WiFiTestContextManager 279 # TODO(crbug.com/1065601): plumb other WiFi test specific arguments, 280 # e.g. pcap address. See: WiFiTestContextManager's constants. 281 forward_args = [ 282 (WiFiManager.CMDLINE_ROUTER_ADDR, 'router=%s'), 283 (WiFiManager.CMDLINE_PCAP_ADDR, 'pcap=%s'), 284 ] 285 for key, var_arg in forward_args: 286 if key in args_dict: 287 args += ['-var=' + var_arg % args_dict[key]] 288 logging.info('Autotest wificell-related args: %s', args) 289 return args 290 291 def _get_cloud_storage_info(self): 292 """Gets the cloud storage bucket URL to pass to tast. 293 294 @returns Cloud storage bucket URL that should be inserted in 295 the command line after "tast run". 296 """ 297 gs_bucket = dev_server._get_image_storage_server() 298 args_dict = utils.args_to_dict(self._command_args) 299 build = args_dict.get('build') 300 if not build: 301 labels = self._host.host_info_store.get().labels 302 build = labellib.LabelsMapping(labels).get( 303 labellib.Key.CROS_VERSION) 304 305 if not gs_bucket or not build: 306 return [] 307 gs_path = gs_bucket + build 308 if not gs_path.endswith('/'): 309 gs_path += '/' 310 logging.info('Cloud storage bucket: %s', gs_path) 311 return ['-buildartifactsurl=%s' % gs_path] 312 313 def _find_devservers(self): 314 """Finds available devservers. 315 316 The result is saved as self._devserver_args. 317 """ 318 devservers, _ = dev_server.ImageServer.get_available_devservers( 319 self._host.hostname, prefer_local_devserver=True) 320 logging.info('Using devservers: %s', ', '.join(devservers)) 321 self._devserver_args = ['-devservers=%s' % ','.join(devservers)] 322 323 def _log_version(self): 324 """Runs the tast command locally to log its version.""" 325 try: 326 utils.run([self._tast_path, '-version'], 327 timeout=self._VERSION_TIMEOUT_SEC, 328 stdout_tee=utils.TEE_TO_LOGS, 329 stderr_tee=utils.TEE_TO_LOGS, 330 stderr_is_expected=True, 331 stdout_level=logging.INFO, 332 stderr_level=logging.ERROR) 333 except error.CmdError as e: 334 logging.error('Failed to log tast version: %s', str(e)) 335 336 def _run_tast(self, subcommand, extra_subcommand_args, timeout_sec, 337 log_stdout=False): 338 """Runs the tast command locally to e.g. list available tests or perform 339 testing against the DUT. 340 341 @param subcommand: Subcommand to pass to the tast executable, e.g. 'run' 342 or 'list'. 343 @param extra_subcommand_args: List of additional subcommand arguments. 344 @param timeout_sec: Integer timeout for the command in seconds. 345 @param log_stdout: If true, write stdout to log. 346 347 @returns client.common_lib.utils.CmdResult object describing the result. 348 349 @raises error.TestFail if the tast command fails or times out. 350 """ 351 cmd = [ 352 self._tast_path, 353 '-verbose=true', 354 '-logtime=false', 355 subcommand, 356 '-sshretries=%d' % self._SSH_CONNECT_RETRIES, 357 '-downloaddata=%s' % ( 358 'lazy' if self._download_data_lazily else 'batch'), 359 ] 360 if self._build: 361 cmd.extend([ 362 '-build=true', 363 '-buildbundle=%s' % self._build_bundle, 364 '-checkbuilddeps=false', 365 ]) 366 else: 367 cmd.append('-build=false') 368 if self._ssp: 369 remote_test_runner_path = self._get_path( 370 self._SSP_REMOTE_TEST_RUNNER_PATH) 371 if not os.path.exists(remote_test_runner_path): 372 raise error.TestFail( 373 '%s does not exist (broken SSP?)' % 374 remote_test_runner_path) 375 cmd.extend([ 376 '-remotebundledir=%s' % self._get_path( 377 self._SSP_REMOTE_BUNDLE_DIR), 378 '-remotedatadir=%s' % self._get_path( 379 self._SSP_REMOTE_DATA_DIR), 380 '-remoterunner=%s' % remote_test_runner_path, 381 ]) 382 if subcommand == 'run': 383 cmd.append('-defaultvarsdir=%s' % 384 self._get_path(self._SSP_DEFAULT_VARS_DIR_PATH)) 385 if self._run_private_tests: 386 cmd.append('-downloadprivatebundles=true') 387 cmd.extend(self._devserver_args) 388 cmd.extend(extra_subcommand_args) 389 cmd.append('%s:%d' % (self._host.hostname, self._host.port)) 390 cmd.extend(self._test_exprs) 391 392 logging.info('Running %s', 393 ' '.join([utils.sh_quote_word(a) for a in cmd])) 394 try: 395 return utils.run(cmd, 396 ignore_status=False, 397 timeout=timeout_sec, 398 stdout_tee=(utils.TEE_TO_LOGS if log_stdout 399 else None), 400 stderr_tee=utils.TEE_TO_LOGS, 401 stderr_is_expected=True, 402 stdout_level=logging.INFO, 403 stderr_level=logging.ERROR) 404 except error.CmdError as e: 405 # The tast command's output generally ends with a line describing 406 # the error that was encountered; include it in the first line of 407 # the TestFail exception. Fall back to stderr if stdout is empty (as 408 # is the case with the "list" subcommand, which uses stdout to print 409 # test data). 410 get_last_line = lambda s: s.strip().split('\n')[-1].strip() 411 last_line = (get_last_line(e.result_obj.stdout) or 412 get_last_line(e.result_obj.stderr)) 413 msg = (' (last line: %s)' % last_line) if last_line else '' 414 raise error.TestFail('Failed to run tast%s: %s' % (msg, str(e))) 415 except error.CmdTimeoutError as e: 416 raise error.TestFail('Got timeout while running tast: %s' % str(e)) 417 418 def _get_tests_to_run(self): 419 """Runs the tast command to update the list of tests that will be run. 420 421 @returns False if no tests matched by test_exprs; True otherwise 422 423 @raises error.TestFail if the tast command fails or times out. 424 """ 425 logging.info('Getting list of tests that will be run') 426 args = ['-json=true'] + self._get_cloud_storage_info() 427 result = self._run_tast('list', args, self._LIST_TIMEOUT_SEC) 428 try: 429 self._tests_to_run = _encode_utf8_json( 430 json.loads(result.stdout.strip())) 431 except ValueError as e: 432 raise error.TestFail('Failed to parse tests: %s' % str(e)) 433 if len(self._tests_to_run) == 0: 434 expr = ' '.join([utils.sh_quote_word(a) for a in self._test_exprs]) 435 logging.warning('No tests matched by %s', expr) 436 return False 437 438 logging.info('Expect to run %d test(s)', len(self._tests_to_run)) 439 return True 440 441 def _run_tests(self): 442 """Runs the tast command to perform testing. 443 444 @raises error.TestFail if the tast command fails or times out (but not 445 if individual tests fail). 446 """ 447 args = [ 448 '-resultsdir=' + self.resultsdir, 449 '-waituntilready=true', 450 '-timeout=' + str(self._max_run_sec), 451 '-continueafterfailure=true', 452 ] + self._get_servo_args() + self._get_wificell_args() + self._get_cloud_storage_info() 453 454 for varsfile in self._varsfiles: 455 args.append('-varsfile=%s' % varsfile) 456 457 for var in self._varslist: 458 args.append('-var=%s' % var) 459 460 logging.info('Running tests with timeout of %d sec', self._max_run_sec) 461 self._run_tast('run', args, self._max_run_sec + tast._RUN_EXIT_SEC, 462 log_stdout=True) 463 464 def _read_run_error(self): 465 """Reads a global run error message written by the tast command.""" 466 # The file is only written if a run error occurred. 467 path = os.path.join(self.resultsdir, self._RUN_ERROR_FILENAME) 468 if os.path.exists(path): 469 with open(path, 'r') as f: 470 self._run_error = f.read().strip() 471 472 def _parse_results(self, ignore_missing_file): 473 """Parses results written by the tast command. 474 475 @param ignore_missing_file: If True, return without raising an exception 476 if the Tast results file is missing. This is used to avoid raising a 477 new error if there was already an earlier error while running the 478 tast process. 479 480 @raises error.TestFail if results file is missing and 481 ignore_missing_file is False, or one or more tests failed and 482 _ignore_test_failures is false. 483 """ 484 # The file may not exist if "tast run" failed to run. Tests that were 485 # seen from the earlier "tast list" command will be reported as having 486 # missing results. 487 path = os.path.join(self.resultsdir, self._STREAMED_RESULTS_FILENAME) 488 if not os.path.exists(path): 489 if ignore_missing_file: 490 return 491 raise error.TestFail('Results file %s not found' % path) 492 493 failed = [] 494 seen_test_names = set() 495 with open(path, 'r') as f: 496 for line in f: 497 line = line.strip() 498 if not line: 499 continue 500 try: 501 test = _encode_utf8_json(json.loads(line)) 502 except ValueError as e: 503 raise error.TestFail('Failed to parse %s: %s' % (path, e)) 504 self._test_results.append(test) 505 506 name = test['name'] 507 seen_test_names.add(name) 508 509 if test.get('errors'): 510 for err in test['errors']: 511 logging.warning('%s: %s', name, err['reason']) 512 failed.append(name) 513 else: 514 # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) 515 # end time (preceding the Unix epoch) if it didn't report 516 # completion. 517 if _rfc3339_time_to_timestamp(test['end']) <= 0: 518 failed.append(name) 519 520 missing = [t['name'] for t in self._tests_to_run 521 if t['name'] not in seen_test_names] 522 523 if missing: 524 self._record_missing_tests(missing) 525 526 failure_msg = self._get_failure_message(failed, missing) 527 if failure_msg: 528 raise error.TestFail(failure_msg) 529 530 def _get_failure_message(self, failed, missing): 531 """Returns an error message describing failed and/or missing tests. 532 533 @param failed: List of string names of Tast tests that failed. 534 @param missing: List of string names of Tast tests with missing results. 535 536 @returns String to be used as error.TestFail message. 537 """ 538 def list_tests(names): 539 """Returns a string listing tests. 540 541 @param names: List of string test names. 542 543 @returns String listing tests. 544 """ 545 s = ' '.join(sorted(names)[:self._MAX_TEST_NAMES_IN_ERROR]) 546 if len(names) > self._MAX_TEST_NAMES_IN_ERROR: 547 s += ' ...' 548 return s 549 550 msg = '' 551 if failed and not self._ignore_test_failures: 552 msg = '%d failed: %s' % (len(failed), list_tests(failed)) 553 if missing: 554 if msg: 555 msg += '; ' 556 msg += '%d missing: %s' % (len(missing), list_tests(missing)) 557 return msg 558 559 def _log_all_tests(self): 560 """Writes entries to the TKO status.log file describing the results of 561 all tests. 562 """ 563 seen_test_names = set() 564 for test in self._test_results: 565 self._log_test(test) 566 seen_test_names.add(test['name']) 567 568 def _log_test(self, test): 569 """Writes events to the TKO status.log file describing the results from 570 a Tast test. 571 572 @param test: A JSON object corresponding to a single test from a Tast 573 results.json file. See TestResult in 574 src/platform/tast/src/chromiumos/cmd/tast/run/results.go for 575 details. 576 """ 577 name = test['name'] 578 start_time = _rfc3339_time_to_timestamp(test['start']) 579 end_time = _rfc3339_time_to_timestamp(test['end']) 580 581 test_reported_errors = bool(test.get('errors')) 582 test_skipped = bool(test.get('skipReason')) 583 # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) end time 584 # (preceding the Unix epoch) if it didn't report completion. 585 test_finished = end_time > 0 586 587 # Avoid reporting tests that were skipped. 588 if test_skipped and not test_reported_errors: 589 return 590 591 self._log_test_event(self._JOB_STATUS_START, name, start_time) 592 593 if test_finished and not test_reported_errors: 594 self._log_test_event(self._JOB_STATUS_GOOD, name, end_time) 595 end_status = self._JOB_STATUS_END_GOOD 596 else: 597 # The previous START event automatically increases the log 598 # indentation level until the following END event. 599 if test_reported_errors: 600 for err in test['errors']: 601 error_time = _rfc3339_time_to_timestamp(err['time']) 602 self._log_test_event(self._JOB_STATUS_FAIL, name, 603 error_time, err['reason']) 604 if not test_finished: 605 # If a run-level error was encountered (e.g. the SSH connection 606 # to the DUT was lost), report it here to make it easier to see 607 # the reason why the test didn't finish. 608 if self._run_error: 609 self._log_test_event(self._JOB_STATUS_FAIL, name, 610 start_time, self._run_error) 611 self._log_test_event(self._JOB_STATUS_FAIL, name, start_time, 612 self._TEST_DID_NOT_FINISH_MSG) 613 end_time = start_time 614 615 end_status = self._JOB_STATUS_END_FAIL 616 617 self._log_test_event(end_status, name, end_time) 618 619 def _log_test_event(self, status_code, test_name, timestamp, message=''): 620 """Logs a single event to the TKO status.log file. 621 622 @param status_code: Event status code, e.g. 'END GOOD'. See 623 client/common_lib/log.py for accepted values. 624 @param test_name: Tast test name, e.g. 'ui.ChromeLogin'. 625 @param timestamp: Event timestamp (as seconds since Unix epoch). 626 @param message: Optional human-readable message. 627 """ 628 full_name = self._TEST_NAME_PREFIX + test_name 629 # The TKO parser code chokes on floating-point timestamps. 630 entry = base_job.status_log_entry(status_code, None, full_name, message, 631 None, timestamp=int(timestamp)) 632 self.job.record_entry(entry, False) 633 634 def _record_missing_tests(self, missing): 635 """Records tests with missing results in job keyval file. 636 637 @param missing: List of string names of Tast tests with missing results. 638 """ 639 keyvals = {} 640 for i, name in enumerate(sorted(missing)): 641 keyvals['%s%d' % (self._MISSING_TEST_KEYVAL_PREFIX, i)] = name 642 utils.write_keyval(self.job.resultdir, keyvals) 643 644 645class _LessBrokenParserInfo(dateutil.parser.parserinfo): 646 """dateutil.parser.parserinfo that interprets years before 100 correctly. 647 648 Our version of dateutil.parser.parse misinteprets an unambiguous string like 649 '0001-01-01T00:00:00Z' as having a two-digit year, which it then converts to 650 2001. This appears to have been fixed by 651 https://github.com/dateutil/dateutil/commit/fc696254. This parserinfo 652 implementation always honors the provided year to prevent this from 653 happening. 654 """ 655 def convertyear(self, year, century_specified=False): 656 """Overrides convertyear in dateutil.parser.parserinfo.""" 657 return int(year) 658 659 660def _rfc3339_time_to_timestamp(time_str): 661 """Converts an RFC3339 time into a Unix timestamp. 662 663 @param time_str: RFC3339-compatible time, e.g. 664 '2018-02-25T07:45:35.916929332-07:00'. 665 666 @returns Float number of seconds since the Unix epoch. Negative if the time 667 precedes the epoch. 668 """ 669 dt = dateutil.parser.parse(time_str, parserinfo=_LessBrokenParserInfo()) 670 return (dt - _UNIX_EPOCH).total_seconds() 671