| #!/usr/bin/env python | |
| # Copyright 2017 The Chromium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Uploads test results artifacts. | |
| This script takes a list of json test results files, the format of which is | |
| described in | |
| https://chromium.googlesource.com/chromium/src/+/master/docs/testing/json_test_results_format.md. | |
| For each file, it looks for test artifacts embedded in each test. It detects | |
| this by looking for the top level "artifact_type_info" key. | |
| The script, by default, uploads every artifact stored on the local disk (a URI | |
| with the 'file' scheme) to google storage. | |
| """ | |
| import argparse | |
| import collections | |
| import copy | |
| import itertools | |
| import json | |
| import hashlib | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import urlparse | |
| import uuid | |
| root_dir = os.path.abspath( | |
| os.path.join(os.path.dirname(__file__), '..', '..', '..')) | |
| sys.path.append(os.path.join(root_dir, 'build', 'android')) | |
| from pylib.utils import google_storage_helper | |
| def get_file_digest(filepath): | |
| """Get the cloud storage path for uploading a file. | |
| Hashes the file contents to determine the filename. | |
| """ | |
| with open(filepath, 'rb') as f: | |
| # TODO: switch to sha256. crbug.com/787113 | |
| m = hashlib.sha1() | |
| while True: | |
| chunk = f.read(64 * 1024) | |
| if not chunk: | |
| break | |
| m.update(chunk) | |
| return m.hexdigest() | |
| def get_tests(test_trie): | |
| """Gets all tests in this test trie. | |
| It detects if an entry is a test by looking for the 'expected' and 'actual' | |
| keys in the dictionary. | |
| The keys of the dictionary are tuples of the keys. A test trie like | |
| "foo": { | |
| "bar": { | |
| "baz": { | |
| "actual": "PASS", | |
| "expected": "PASS", | |
| } | |
| } | |
| } | |
| Would give you | |
| { | |
| ('foo', 'bar', 'baz'): { | |
| "actual": "PASS", | |
| "expected": "PASS", | |
| } | |
| } | |
| NOTE: If you are getting an error with a stack trace ending in this function, | |
| file a bug with crbug.com/new and cc martiniss@. | |
| """ | |
| if not isinstance(test_trie, dict): | |
| raise ValueError("expected %s to be a dict" % test_trie) | |
| tests = {} | |
| for k, v in test_trie.items(): | |
| if 'expected' in v and 'actual' in v: | |
| tests[(k,)] = v | |
| else: | |
| for key, val in get_tests(v).items(): | |
| tests[(k,) + key] = val | |
| return tests | |
| def upload_directory_to_gs(local_path, bucket, gs_path, dry_run): | |
| if dry_run: | |
| print 'would have uploaded %s to %s' % (local_path, gs_path) | |
| return | |
| # -m does multithreaded uploads, which is needed because we upload multiple | |
| # files. -r copies the whole directory. | |
| google_storage_helper.upload( | |
| gs_path, local_path, bucket, gs_args=['-m'], command_args=['-r']) | |
| def hash_artifacts(tests, artifact_root): | |
| hashed_artifacts = [] | |
| # Sort for testing consistency. | |
| for test_obj in sorted(tests.values()): | |
| for name, location in sorted( | |
| test_obj.get('artifacts', {}).items(), | |
| key=lambda pair: pair[0]): | |
| absolute_filepath = os.path.join(artifact_root, location) | |
| file_digest = get_file_digest(absolute_filepath) | |
| # Location is set to file digest because it's relative to the google | |
| # storage root. | |
| test_obj['artifacts'][name] = file_digest | |
| hashed_artifacts.append((file_digest, absolute_filepath)) | |
| return hashed_artifacts | |
| def prep_artifacts_for_gs_upload(hashed_artifacts, tempdir): | |
| for file_digest, absolute_filepath in hashed_artifacts: | |
| new_location = os.path.join(tempdir, file_digest) | |
| # Since we used content addressed hashing, the file might already exist. | |
| if not os.path.exists(new_location): | |
| shutil.copyfile(absolute_filepath, new_location) | |
| def upload_artifacts(data, artifact_root, dry_run, bucket): | |
| """Uploads artifacts to google storage. | |
| Args: | |
| * data: The test results data to upload. Assumed to include 'tests' and | |
| 'artifact_type_info' top level keys. | |
| * artifact_root: The local directory where artifact locations are relative | |
| to. | |
| * dry_run: If true, this run is a test run, and no actual changes should be | |
| made. This includes no uploading any data to cloud storage. | |
| Returns: | |
| The test results data, with rewritten artifact locations. | |
| """ | |
| local_data = copy.deepcopy(data) | |
| type_info = local_data['artifact_type_info'] | |
| # Put the hashing algorithm as part of the filename, so that it's | |
| # easier to change the algorithm if we need to in the future. | |
| gs_path = 'sha1' | |
| tests = get_tests(local_data['tests']) | |
| # Do a validation pass first. Makes sure no filesystem operations occur if | |
| # there are invalid artifacts. | |
| for test_obj in tests.values(): | |
| for artifact_name in test_obj.get('artifacts', {}): | |
| if artifact_name not in type_info: | |
| raise ValueError( | |
| 'Artifact %r type information not present' % artifact_name) | |
| tempdir = tempfile.mkdtemp(prefix='upload_test_artifacts') | |
| try: | |
| hashed_artifacts = hash_artifacts(tests, artifact_root) | |
| prep_artifacts_for_gs_upload(hashed_artifacts, tempdir) | |
| # Add * to include all files in that directory. | |
| upload_directory_to_gs( | |
| os.path.join(tempdir, '*'), bucket, gs_path, dry_run) | |
| local_data['artifact_permanent_location'] = 'gs://%s/%s' % ( | |
| bucket, gs_path) | |
| return local_data | |
| finally: | |
| shutil.rmtree(tempdir) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| # This would be test-result-file, but argparse doesn't translate | |
| # test-result-file to args.test_result_file automatically, and dest doesn't | |
| # seem to work on positional arguments. | |
| parser.add_argument('test_result_file') | |
| parser.add_argument('--output-file', type=os.path.realpath, | |
| help='If set, the input json test results file will be' | |
| ' rewritten to include new artifact location data, and' | |
| ' dumped to this value.') | |
| parser.add_argument('-n', '--dry-run', action='store_true', | |
| help='If true, this script will not upload any files, and' | |
| ' will instead just print to stdout what path it' | |
| ' would have uploaded each file. Useful for testing.' | |
| ) | |
| parser.add_argument('--artifact-root', required=True, type=os.path.realpath, | |
| help='The file path where artifact locations are rooted.') | |
| parser.add_argument('--bucket', default='chromium-test-artifacts', | |
| help='The google storage bucket to upload artifacts to.' | |
| ' The default bucket is public and accessible by anyone.') | |
| parser.add_argument('-q', '--quiet', action='store_true', | |
| help='If set, does not print the transformed json file' | |
| ' to stdout.') | |
| args = parser.parse_args() | |
| with open(args.test_result_file) as f: | |
| data = json.load(f) | |
| type_info = data.get('artifact_type_info') | |
| if not type_info: | |
| print 'File %r did not have %r top level key. Not processing.' % ( | |
| args.test_result_file, 'artifact_type_info') | |
| return 1 | |
| new_data = upload_artifacts( | |
| data, args.artifact_root, args.dry_run, args.bucket) | |
| if args.output_file: | |
| with open(args.output_file, 'w') as f: | |
| json.dump(new_data, f) | |
| if new_data and not args.quiet: | |
| print json.dumps( | |
| new_data, indent=2, separators=(',', ': '), sort_keys=True) | |
| return 0 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |