|
| 1 | +# Copyright 2018 the V8 project 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 | + |
| 5 | +""" |
| 6 | +Presubmit checks for the validity of V8-side test specifications in pyl files. |
| 7 | +
|
| 8 | +For simplicity, we check all pyl files on any changes in this folder. |
| 9 | +""" |
| 10 | + |
| 11 | +import ast |
| 12 | +import os |
| 13 | + |
| 14 | + |
| 15 | +SUPPORTED_BUILDER_SPEC_KEYS = [ |
| 16 | + 'swarming_dimensions', |
| 17 | + 'swarming_task_attrs', |
| 18 | + 'tests', |
| 19 | +] |
| 20 | + |
| 21 | +# This is not an exhaustive list. It only reflects what we currently use. If |
| 22 | +# there's need to specify a different dimension, just add it here. |
| 23 | +SUPPORTED_SWARMING_DIMENSIONS = [ |
| 24 | + 'cores' |
| 25 | + 'cpu', |
| 26 | + 'os', |
| 27 | +] |
| 28 | + |
| 29 | +# This is not an exhaustive list. It only reflects what we currently use. If |
| 30 | +# there's need to specify a different property, just add it here. |
| 31 | +SUPPORTED_SWARMING_TASK_ATTRS = [ |
| 32 | + 'expiration', |
| 33 | + 'hard_timeout', |
| 34 | + 'priority', |
| 35 | +] |
| 36 | + |
| 37 | +SUPPORTED_TEST_KEYS = [ |
| 38 | + 'name', |
| 39 | + 'shards', |
| 40 | + 'suffix', |
| 41 | + 'swarming_dimensions', |
| 42 | + 'swarming_task_attrs', |
| 43 | + 'test_args', |
| 44 | + 'variant', |
| 45 | +] |
| 46 | + |
| 47 | +def check_keys(error_msg, src_dict, supported_keys): |
| 48 | + errors = [] |
| 49 | + for key in src_dict.keys(): |
| 50 | + if key not in supported_keys: |
| 51 | + errors += error_msg('Key "%s" must be one of %s' % (key, supported_keys)) |
| 52 | + return errors |
| 53 | + |
| 54 | + |
| 55 | +def _check_properties(error_msg, src_dict, prop_name, supported_keys): |
| 56 | + properties = src_dict.get(prop_name, {}) |
| 57 | + if not isinstance(properties, dict): |
| 58 | + return error_msg('Value for %s must be a dict' % prop_name) |
| 59 | + return check_keys(error_msg, properties, supported_keys) |
| 60 | + |
| 61 | + |
| 62 | +def _check_int_range(error_msg, src_dict, prop_name, lower_bound=None, |
| 63 | + upper_bound=None): |
| 64 | + if prop_name not in src_dict: |
| 65 | + # All properties are optional. |
| 66 | + return [] |
| 67 | + try: |
| 68 | + value = int(src_dict[prop_name]) |
| 69 | + except ValueError: |
| 70 | + return error_msg('If specified, %s must be an int' % prop_name) |
| 71 | + if lower_bound is not None and value < lower_bound: |
| 72 | + return error_msg('If specified, %s must be >=%d' % (prop_name, lower_bound)) |
| 73 | + if upper_bound is not None and value > upper_bound: |
| 74 | + return error_msg('If specified, %s must be <=%d' % (prop_name, upper_bound)) |
| 75 | + return [] |
| 76 | + |
| 77 | + |
| 78 | +def _check_swarming_task_attrs(error_msg, src_dict): |
| 79 | + errors = [] |
| 80 | + task_attrs = src_dict.get('swarming_task_attrs', {}) |
| 81 | + errors += _check_int_range( |
| 82 | + error_msg, task_attrs, 'priority', lower_bound=25, upper_bound=100) |
| 83 | + errors += _check_int_range( |
| 84 | + error_msg, task_attrs, 'expiration', lower_bound=1) |
| 85 | + errors += _check_int_range( |
| 86 | + error_msg, task_attrs, 'hard_timeout', lower_bound=1) |
| 87 | + return errors |
| 88 | + |
| 89 | + |
| 90 | +def _check_swarming_config(error_msg, src_dict): |
| 91 | + errors = [] |
| 92 | + errors += _check_properties( |
| 93 | + error_msg, src_dict, 'swarming_dimensions', |
| 94 | + SUPPORTED_SWARMING_DIMENSIONS) |
| 95 | + errors += _check_properties( |
| 96 | + error_msg, src_dict, 'swarming_task_attrs', |
| 97 | + SUPPORTED_SWARMING_TASK_ATTRS) |
| 98 | + errors += _check_swarming_task_attrs(error_msg, src_dict) |
| 99 | + return errors |
| 100 | + |
| 101 | + |
| 102 | +def _check_test(error_msg, test): |
| 103 | + if not isinstance(test, dict): |
| 104 | + return error_msg('Each test must be specified with a dict') |
| 105 | + errors = check_keys(error_msg, test, SUPPORTED_TEST_KEYS) |
| 106 | + if not test.get('name'): |
| 107 | + errors += error_msg('A test requires a name') |
| 108 | + errors += _check_swarming_config(error_msg, test) |
| 109 | + |
| 110 | + test_args = test.get('test_args', []) |
| 111 | + if not isinstance(test_args, list): |
| 112 | + errors += error_msg('If specified, test_args must be a list of arguments') |
| 113 | + if not all(isinstance(x, basestring) for x in test_args): |
| 114 | + errors += error_msg('If specified, all test_args must be strings') |
| 115 | + |
| 116 | + # Limit shards to 10 to avoid erroneous resource exhaustion. |
| 117 | + errors += _check_int_range( |
| 118 | + error_msg, test, 'shards', lower_bound=1, upper_bound=10) |
| 119 | + |
| 120 | + variant = test.get('variant', 'default') |
| 121 | + if not variant or not isinstance(variant, basestring): |
| 122 | + errors += error_msg('If specified, variant must be a non-empty string') |
| 123 | + |
| 124 | + return errors |
| 125 | + |
| 126 | + |
| 127 | +def _check_test_spec(file_path, raw_pyl): |
| 128 | + def error_msg(msg): |
| 129 | + return ['Error in %s:\n%s' % (file_path, msg)] |
| 130 | + |
| 131 | + try: |
| 132 | + # Eval python literal file. |
| 133 | + full_test_spec = ast.literal_eval(raw_pyl) |
| 134 | + except SyntaxError as e: |
| 135 | + return error_msg('Pyl parsing failed with:\n%s' % e) |
| 136 | + |
| 137 | + if not isinstance(full_test_spec, dict): |
| 138 | + return error_msg('Test spec must be a dict') |
| 139 | + |
| 140 | + errors = [] |
| 141 | + for buildername, builder_spec in full_test_spec.iteritems(): |
| 142 | + def error_msg(msg): |
| 143 | + return ['Error in %s for builder %s:\n%s' % (file_path, buildername, msg)] |
| 144 | + |
| 145 | + if not isinstance(buildername, basestring) or not buildername: |
| 146 | + errors += error_msg('Buildername must be a non-empty string') |
| 147 | + |
| 148 | + if not isinstance(builder_spec, dict) or not builder_spec: |
| 149 | + errors += error_msg('Value must be a non-empty dict') |
| 150 | + continue |
| 151 | + |
| 152 | + errors += check_keys(error_msg, builder_spec, SUPPORTED_BUILDER_SPEC_KEYS) |
| 153 | + errors += _check_swarming_config(error_msg, builder_spec) |
| 154 | + |
| 155 | + for test in builder_spec.get('tests', []): |
| 156 | + errors += _check_test(error_msg, test) |
| 157 | + |
| 158 | + return errors |
| 159 | + |
| 160 | + |
| 161 | + |
| 162 | +def CheckChangeOnCommit(input_api, output_api): |
| 163 | + def file_filter(regexp): |
| 164 | + return lambda f: input_api.FilterSourceFile(f, white_list=(regexp,)) |
| 165 | + |
| 166 | + # Calculate which files are affected. |
| 167 | + if input_api.AffectedFiles(False, file_filter(r'.*PRESUBMIT\.py')): |
| 168 | + # If PRESUBMIT.py itself was changed, check all configs. |
| 169 | + affected_files = [ |
| 170 | + f for f in os.listdir(input_api.PresubmitLocalPath()) |
| 171 | + if f.endswith('.pyl') |
| 172 | + ] |
| 173 | + else: |
| 174 | + # Otherwise, check only changed configs. |
| 175 | + affected_files = [ |
| 176 | + f.AbsoluteLocalPath() |
| 177 | + for f in input_api.AffectedFiles(False, file_filter(r'.+\.pyl')) |
| 178 | + ] |
| 179 | + |
| 180 | + errors = [] |
| 181 | + for file_path in affected_files: |
| 182 | + with open(file_path) as f: |
| 183 | + errors += _check_test_spec(file_path, f.read()) |
| 184 | + return [output_api.PresubmitError(r) for r in errors] |
0 commit comments