| #!/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()) |