| # Copyright (C) 2010 Google Inc. All rights reserved. |
| # |
| # Redistribution and use in source and binary forms, with or without |
| # modification, are permitted provided that the following conditions are |
| # met: |
| # |
| # * Redistributions of source code must retain the above copyright |
| # notice, this list of conditions and the following disclaimer. |
| # * Redistributions in binary form must reproduce the above |
| # copyright notice, this list of conditions and the following disclaimer |
| # in the documentation and/or other materials provided with the |
| # distribution. |
| # * Neither the name of Google Inc. nor the names of its |
| # contributors may be used to endorse or promote products derived from |
| # this software without specific prior written permission. |
| # |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| from datetime import datetime |
| from django.utils import simplejson |
| import logging |
| |
| from model.testfile import TestFile |
| |
| JSON_RESULTS_FILE = "results.json" |
| JSON_RESULTS_FILE_SMALL = "results-small.json" |
| JSON_RESULTS_PREFIX = "ADD_RESULTS(" |
| JSON_RESULTS_SUFFIX = ");" |
| JSON_RESULTS_VERSION_KEY = "version" |
| JSON_RESULTS_BUILD_NUMBERS = "buildNumbers" |
| JSON_RESULTS_TESTS = "tests" |
| JSON_RESULTS_RESULTS = "results" |
| JSON_RESULTS_TIMES = "times" |
| JSON_RESULTS_PASS = "P" |
| JSON_RESULTS_NO_DATA = "N" |
| JSON_RESULTS_MIN_TIME = 1 |
| JSON_RESULTS_VERSION = 3 |
| JSON_RESULTS_MAX_BUILDS = 750 |
| JSON_RESULTS_MAX_BUILDS_SMALL = 200 |
| |
| |
| class JsonResults(object): |
| @classmethod |
| def _strip_prefix_suffix(cls, data): |
| """Strip out prefix and suffix of json results string. |
| |
| Args: |
| data: json file content. |
| |
| Returns: |
| json string without prefix and suffix. |
| """ |
| |
| assert(data.startswith(JSON_RESULTS_PREFIX)) |
| assert(data.endswith(JSON_RESULTS_SUFFIX)) |
| |
| return data[len(JSON_RESULTS_PREFIX): |
| len(data) - len(JSON_RESULTS_SUFFIX)] |
| |
| @classmethod |
| def _generate_file_data(cls, json, sort_keys=False): |
| """Given json string, generate file content data by adding |
| prefix and suffix. |
| |
| Args: |
| json: json string without prefix and suffix. |
| |
| Returns: |
| json file data. |
| """ |
| |
| data = simplejson.dumps(json, separators=(',', ':'), |
| sort_keys=sort_keys) |
| return JSON_RESULTS_PREFIX + data + JSON_RESULTS_SUFFIX |
| |
| @classmethod |
| def _load_json(cls, file_data): |
| """Load json file to a python object. |
| |
| Args: |
| file_data: json file content. |
| |
| Returns: |
| json object or |
| None on failure. |
| """ |
| |
| json_results_str = cls._strip_prefix_suffix(file_data) |
| if not json_results_str: |
| logging.warning("No json results data.") |
| return None |
| |
| try: |
| return simplejson.loads(json_results_str) |
| except Exception, err: |
| logging.debug(json_results_str) |
| logging.error("Failed to load json results: %s", str(err)) |
| return None |
| |
| @classmethod |
| def _merge_json(cls, aggregated_json, incremental_json, num_runs): |
| """Merge incremental json into aggregated json results. |
| |
| Args: |
| aggregated_json: aggregated json object. |
| incremental_json: incremental json object. |
| num_runs: number of total runs to include. |
| |
| Returns: |
| True if merge succeeds or |
| False on failure. |
| """ |
| |
| # Merge non tests property data. |
| # Tests properties are merged in _merge_tests. |
| if not cls._merge_non_test_data(aggregated_json, incremental_json, num_runs): |
| return False |
| |
| # Merge tests results and times |
| incremental_tests = incremental_json[JSON_RESULTS_TESTS] |
| if incremental_tests: |
| aggregated_tests = aggregated_json[JSON_RESULTS_TESTS] |
| cls._merge_tests(aggregated_tests, incremental_tests, num_runs) |
| |
| return True |
| |
| @classmethod |
| def _merge_non_test_data(cls, aggregated_json, incremental_json, num_runs): |
| """Merge incremental non tests property data into aggregated json results. |
| |
| Args: |
| aggregated_json: aggregated json object. |
| incremental_json: incremental json object. |
| num_runs: number of total runs to include. |
| |
| Returns: |
| True if merge succeeds or |
| False on failure. |
| """ |
| |
| incremental_builds = incremental_json[JSON_RESULTS_BUILD_NUMBERS] |
| aggregated_builds = aggregated_json[JSON_RESULTS_BUILD_NUMBERS] |
| aggregated_build_number = int(aggregated_builds[0]) |
| # Loop through all incremental builds, start from the oldest run. |
| for index in reversed(range(len(incremental_builds))): |
| build_number = int(incremental_builds[index]) |
| logging.debug("Merging build %s, incremental json index: %d.", |
| build_number, index) |
| |
| # Return if not all build numbers in the incremental json results |
| # are newer than the most recent build in the aggregated results. |
| # FIXME: make this case work. |
| if build_number < aggregated_build_number: |
| logging.warning(("Build %d in incremental json is older than " |
| "the most recent build in aggregated results: %d"), |
| build_number, aggregated_build_number) |
| return False |
| |
| # Return if the build number is duplicated. |
| # FIXME: skip the duplicated build and merge rest of the results. |
| # Need to be careful on skiping the corresponding value in |
| # _merge_tests because the property data for each test could |
| # be accumulated. |
| if build_number == aggregated_build_number: |
| logging.warning("Duplicate build %d in incremental json", |
| build_number) |
| return False |
| |
| # Merge this build into aggreagated results. |
| cls._merge_one_build(aggregated_json, incremental_json, index, num_runs) |
| |
| return True |
| |
| @classmethod |
| def _merge_one_build(cls, aggregated_json, incremental_json, |
| incremental_index, num_runs): |
| """Merge one build of incremental json into aggregated json results. |
| |
| Args: |
| aggregated_json: aggregated json object. |
| incremental_json: incremental json object. |
| incremental_index: index of the incremental json results to merge. |
| num_runs: number of total runs to include. |
| """ |
| |
| for key in incremental_json.keys(): |
| # Merge json results except "tests" properties (results, times etc). |
| # "tests" properties will be handled separately. |
| if key == JSON_RESULTS_TESTS: |
| continue |
| |
| if key in aggregated_json: |
| aggregated_json[key].insert( |
| 0, incremental_json[key][incremental_index]) |
| aggregated_json[key] = \ |
| aggregated_json[key][:num_runs] |
| else: |
| aggregated_json[key] = incremental_json[key] |
| |
| @classmethod |
| def _merge_tests(cls, aggregated_json, incremental_json, num_runs): |
| """Merge "tests" properties:results, times. |
| |
| Args: |
| aggregated_json: aggregated json object. |
| incremental_json: incremental json object. |
| num_runs: number of total runs to include. |
| """ |
| |
| all_tests = (set(aggregated_json.iterkeys()) | |
| set(incremental_json.iterkeys())) |
| for test_name in all_tests: |
| if test_name in aggregated_json: |
| aggregated_test = aggregated_json[test_name] |
| if test_name in incremental_json: |
| incremental_test = incremental_json[test_name] |
| results = incremental_test[JSON_RESULTS_RESULTS] |
| times = incremental_test[JSON_RESULTS_TIMES] |
| else: |
| results = [[1, JSON_RESULTS_NO_DATA]] |
| times = [[1, 0]] |
| |
| cls._insert_item_run_length_encoded( |
| results, aggregated_test[JSON_RESULTS_RESULTS], num_runs) |
| cls._insert_item_run_length_encoded( |
| times, aggregated_test[JSON_RESULTS_TIMES], num_runs) |
| cls._normalize_results_json(test_name, aggregated_json, num_runs) |
| else: |
| aggregated_json[test_name] = incremental_json[test_name] |
| |
| @classmethod |
| def _insert_item_run_length_encoded(cls, incremental_item, aggregated_item, num_runs): |
| """Inserts the incremental run-length encoded results into the aggregated |
| run-length encoded results. |
| |
| Args: |
| incremental_item: incremental run-length encoded results. |
| aggregated_item: aggregated run-length encoded results. |
| num_runs: number of total runs to include. |
| """ |
| |
| for item in incremental_item: |
| if len(aggregated_item) and item[1] == aggregated_item[0][1]: |
| aggregated_item[0][0] = min( |
| aggregated_item[0][0] + item[0], num_runs) |
| else: |
| aggregated_item.insert(0, item) |
| |
| @classmethod |
| def _normalize_results_json(cls, test_name, aggregated_json, num_runs): |
| """ Prune tests where all runs pass or tests that no longer exist and |
| truncate all results to num_runs. |
| |
| Args: |
| test_name: Name of the test. |
| aggregated_json: The JSON object with all the test results for |
| this builder. |
| num_runs: number of total runs to include. |
| """ |
| |
| aggregated_test = aggregated_json[test_name] |
| aggregated_test[JSON_RESULTS_RESULTS] = \ |
| cls._remove_items_over_max_number_of_builds( |
| aggregated_test[JSON_RESULTS_RESULTS], num_runs) |
| aggregated_test[JSON_RESULTS_TIMES] = \ |
| cls._remove_items_over_max_number_of_builds( |
| aggregated_test[JSON_RESULTS_TIMES], num_runs) |
| |
| is_all_pass = cls._is_results_all_of_type( |
| aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_PASS) |
| is_all_no_data = cls._is_results_all_of_type( |
| aggregated_test[JSON_RESULTS_RESULTS], JSON_RESULTS_NO_DATA) |
| |
| max_time = max( |
| [time[1] for time in aggregated_test[JSON_RESULTS_TIMES]]) |
| # Remove all passes/no-data from the results to reduce noise and |
| # filesize. If a test passes every run, but |
| # takes >= JSON_RESULTS_MIN_TIME to run, don't throw away the data. |
| if (is_all_no_data or |
| (is_all_pass and max_time < JSON_RESULTS_MIN_TIME)): |
| del aggregated_json[test_name] |
| |
| @classmethod |
| def _remove_items_over_max_number_of_builds(cls, encoded_list, num_runs): |
| """Removes items from the run-length encoded list after the final |
| item that exceeds the max number of builds to track. |
| |
| Args: |
| encoded_results: run-length encoded results. An array of arrays, e.g. |
| [[3,'A'],[1,'Q']] encodes AAAQ. |
| num_runs: number of total runs to include. |
| """ |
| num_builds = 0 |
| index = 0 |
| for result in encoded_list: |
| num_builds = num_builds + result[0] |
| index = index + 1 |
| if num_builds >= num_runs: |
| return encoded_list[:index] |
| |
| return encoded_list |
| |
| @classmethod |
| def _is_results_all_of_type(cls, results, type): |
| """Returns whether all the results are of the given type |
| (e.g. all passes). |
| """ |
| |
| return len(results) == 1 and results[0][1] == type |
| |
| @classmethod |
| def _check_json(cls, builder, json): |
| """Check whether the given json is valid. |
| |
| Args: |
| builder: builder name this json is for. |
| json: json object to check. |
| |
| Returns: |
| True if the json is valid or |
| False otherwise. |
| """ |
| |
| version = json[JSON_RESULTS_VERSION_KEY] |
| if version > JSON_RESULTS_VERSION: |
| logging.error("Results JSON version '%s' is not supported.", |
| version) |
| return False |
| |
| if not builder in json: |
| logging.error("Builder '%s' is not in json results.", builder) |
| return False |
| |
| results_for_builder = json[builder] |
| if not JSON_RESULTS_BUILD_NUMBERS in results_for_builder: |
| logging.error("Missing build number in json results.") |
| return False |
| |
| return True |
| |
| @classmethod |
| def merge(cls, builder, aggregated, incremental, num_runs, sort_keys=False): |
| """Merge incremental json file data with aggregated json file data. |
| |
| Args: |
| builder: builder name. |
| aggregated: aggregated json file data. |
| incremental: incremental json file data. |
| sort_key: whether or not to sort key when dumping json results. |
| |
| Returns: |
| Merged json file data if merge succeeds or |
| None on failure. |
| """ |
| |
| if not incremental: |
| logging.warning("Nothing to merge.") |
| return None |
| |
| logging.info("Loading incremental json...") |
| incremental_json = cls._load_json(incremental) |
| if not incremental_json: |
| return None |
| |
| logging.info("Checking incremental json...") |
| if not cls._check_json(builder, incremental_json): |
| return None |
| |
| logging.info("Loading existing aggregated json...") |
| aggregated_json = cls._load_json(aggregated) |
| if not aggregated_json: |
| return incremental |
| |
| logging.info("Checking existing aggregated json...") |
| if not cls._check_json(builder, aggregated_json): |
| return incremental |
| |
| logging.info("Merging json results...") |
| try: |
| if not cls._merge_json(aggregated_json[builder], incremental_json[builder], num_runs): |
| return None |
| except Exception, err: |
| logging.error("Failed to merge json results: %s", str(err)) |
| return None |
| |
| aggregated_json[JSON_RESULTS_VERSION_KEY] = JSON_RESULTS_VERSION |
| |
| return cls._generate_file_data(aggregated_json, sort_keys) |
| |
| @classmethod |
| def update(cls, master, builder, test_type, incremental): |
| """Update datastore json file data by merging it with incremental json |
| file. Writes the large file and a small file. The small file just stores |
| fewer runs. |
| |
| Args: |
| master: master name. |
| builder: builder name. |
| test_type: type of test results. |
| incremental: incremental json file data to merge. |
| |
| Returns: |
| Large TestFile object if update succeeds or |
| None on failure. |
| """ |
| small_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE_SMALL, JSON_RESULTS_MAX_BUILDS_SMALL) |
| large_file_updated = cls.update_file(master, builder, test_type, incremental, JSON_RESULTS_FILE, JSON_RESULTS_MAX_BUILDS) |
| |
| return small_file_updated and large_file_updated |
| |
| @classmethod |
| def update_file(cls, master, builder, test_type, incremental, filename, num_runs): |
| files = TestFile.get_files(master, builder, test_type, filename) |
| if files: |
| file = files[0] |
| new_results = cls.merge(builder, file.data, incremental, num_runs) |
| else: |
| # Use the incremental data if there is no aggregated file to merge. |
| file = TestFile() |
| file.master = master |
| file.builder = builder |
| file.test_type = test_type |
| file.name = filename |
| new_results = incremental |
| logging.info("No existing json results, incremental json is saved.") |
| |
| if not new_results or not file.save(new_results): |
| logging.info( |
| "Update failed, master: %s, builder: %s, test_type: %s, name: %s." % |
| (master, builder, test_type, filename)) |
| return False |
| |
| return True |
| |
| @classmethod |
| def get_test_list(cls, builder, json_file_data): |
| """Get list of test names from aggregated json file data. |
| |
| Args: |
| json_file_data: json file data that has all test-data and |
| non-test-data. |
| |
| Returns: |
| json file with test name list only. The json format is the same |
| as the one saved in datastore, but all non-test-data and test detail |
| results are removed. |
| """ |
| |
| logging.debug("Loading test results json...") |
| json = cls._load_json(json_file_data) |
| if not json: |
| return None |
| |
| logging.debug("Checking test results json...") |
| if not cls._check_json(builder, json): |
| return None |
| |
| test_list_json = {} |
| tests = json[builder][JSON_RESULTS_TESTS] |
| test_list_json[builder] = { |
| "tests": dict.fromkeys(tests, {})} |
| |
| return cls._generate_file_data(test_list_json) |