Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # Copyright 2020 The Chromium OS Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | """Determine which platform/model configs match a set of field conditions. |
| 7 | |
| 8 | Each condition passed in as a command-line arg must be formatted as |
| 9 | ${FIELD}${OPERATOR}${VALUE}, where: |
| 10 | ${FIELD} is the name of the field being matched |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 11 | ${OPERATOR} can be any of the following: |
| 12 | = | field equals |
| 13 | != | field does not equal |
| 14 | : | array contains |
| 15 | !: | array does not contain |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 16 | < | number is less than |
| 17 | <= | number is less than or equal to |
| 18 | > | number is greater than |
| 19 | >= | number is greater than or equal to |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 20 | ${VALUE} is the field-value being compared against |
| 21 | |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 22 | Note that Bash tends to misinterpret '!', so users are advised to wrap |
| 23 | conditions in quotes, as in the following example: |
| 24 | ./query_by_field.py 'ec_capability!:x86' |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 25 | """ |
| 26 | |
| 27 | import argparse |
| 28 | import re |
| 29 | import sys |
| 30 | |
| 31 | import platform_json |
| 32 | |
| 33 | |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 34 | # Regex to match an entire condition: field name, operator, expected value. |
| 35 | RE_CONDITION = re.compile(r'^(\w+)(!?[=:]|[<>]=?)(\w+)$') |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 36 | |
| 37 | |
| 38 | class ConditionError(ValueError): |
| 39 | """Error class for when a condition argument is malformed.""" |
| 40 | pass |
| 41 | |
| 42 | |
| 43 | class UnrecognizedOperatorError(ValueError): |
| 44 | """Error class for when a query contains an unexpected operator.""" |
| 45 | |
| 46 | |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 47 | class NotIterableError(ValueError): |
| 48 | """Error class for querying for a member of a non-iterable field.""" |
| 49 | pass |
| 50 | |
| 51 | |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 52 | class NotNumericError(ValueError): |
| 53 | """Error class for when a non-number is used for numeric comparison.""" |
| 54 | |
| 55 | |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 56 | class Condition(object): |
| 57 | """A condition for whether a config's field matches an expected rule.""" |
| 58 | |
| 59 | def __init__(self, string_condition): |
| 60 | """Parse string_condition to capture the field, operator, and value.""" |
| 61 | match = RE_CONDITION.match(string_condition) |
| 62 | if match is None: |
| 63 | raise ConditionError(string_condition) |
| 64 | self.field, self.operator, self.value = match.groups() |
| 65 | # All string comparisons are case-insensitive, so normalize to lower. |
| 66 | self.value = self.value.lower() |
| 67 | |
| 68 | def evaluate(self, config_dict): |
| 69 | """Determine whether a config dict satisfies this Condition.""" |
| 70 | if self.field not in config_dict: |
| 71 | raise platform_json.FieldNotFoundError('%s (platform=%s)' % ( |
| 72 | self.field, config_dict['platform'])) |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 73 | actual_value = config_dict[self.field] |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 74 | if self.operator == '=': |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 75 | return str(actual_value).lower() == self.value |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 76 | elif self.operator == '!=': |
Greg Edelston | b3efad2 | 2020-11-24 11:09:39 -0700 | [diff] [blame] | 77 | return str(actual_value).lower() != self.value |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 78 | elif self.operator in (':', '!:'): |
| 79 | return self._evaluate_array_membership(actual_value) |
| 80 | elif self.operator in ('<', '>', '<=', '>='): |
| 81 | return self._evaluate_numeric(actual_value) |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 82 | else: |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 83 | raise UnrecognizedOperatorError('%s (expect =/!=/:/!:/</<=/>/>=)' % |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 84 | self.operator) |
| 85 | |
Greg Edelston | 6f4f260 | 2020-11-25 16:36:11 -0700 | [diff] [blame] | 86 | def _evaluate_array_membership(self, actual_value): |
| 87 | """Handler for operators that expect array-type config values.""" |
| 88 | if not isinstance(actual_value, list): |
| 89 | msg = 'field %s, actual value %s' % (self.field, actual_value) |
| 90 | raise NotIterableError(msg) |
| 91 | array_contains_expected_element = False |
| 92 | for elem in actual_value: |
| 93 | if str(elem).lower() == self.value: |
| 94 | array_contains_expected_element = True |
| 95 | break |
| 96 | if self.operator == ':': |
| 97 | return array_contains_expected_element |
| 98 | elif self.operator == '!:': |
| 99 | return not array_contains_expected_element |
| 100 | raise UnrecognizedOperatorError(self.operator) |
| 101 | |
| 102 | def _evaluate_numeric(self, actual_value): |
| 103 | """Handler for operators that expect numeric config values.""" |
| 104 | try: |
| 105 | actual_value = float(actual_value) |
| 106 | except (ValueError, TypeError): |
| 107 | msg = 'field %s, actual value %s' % (self.field, actual_value) |
| 108 | raise NotNumericError(msg) |
| 109 | try: |
| 110 | expected_value = float(self.value) |
| 111 | except ValueError: |
| 112 | msg = 'field %s, expected value %s' % (self.field, self.value) |
| 113 | raise NotNumericError(msg) |
| 114 | return eval('%s %s %s' % (actual_value, self.operator, expected_value)) |
| 115 | |
| 116 | |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 117 | def __repr__(self): |
| 118 | """Represent this condition as a string.""" |
| 119 | return '{Condition:%s%s%s}' % (self.field, self.operator, self.value) |
| 120 | |
| 121 | |
| 122 | def parse_args(argv): |
| 123 | """Parse command-line args.""" |
| 124 | parser = argparse.ArgumentParser() |
| 125 | parser.add_argument('conditions', nargs='+', |
| 126 | help='Field-matching requirements, each of the form ' |
Greg Edelston | cc8d763 | 2020-12-07 15:09:20 -0700 | [diff] [blame] | 127 | '${FIELD}${OPERATOR}${VALUE}. Valid operators ' |
| 128 | 'are: = (match), != (not match), : (array ' |
| 129 | 'contains), !: (array does not contain), and ' |
| 130 | 'numeric comparions <, >, <=, >=. Note: String ' |
Greg Edelston | 64fdc2e | 2020-11-19 15:04:18 -0700 | [diff] [blame] | 131 | 'comparisons are always case-insensitive.') |
| 132 | parser.add_argument('-c', '--consolidated', default='CONSOLIDATED.json', |
| 133 | help='The filepath to CONSOLIDATED.json') |
| 134 | args = parser.parse_args(argv) |
| 135 | return args |
| 136 | |
| 137 | |
| 138 | def all_satisfied(conditions, config): |
| 139 | """Determine whether the config satisfies all Conditions.""" |
| 140 | return all([cond.evaluate(config) for cond in conditions]) |
| 141 | |
| 142 | |
| 143 | def main(argv): |
| 144 | """Determine which platforms/models satisfy the passed-in conditions.""" |
| 145 | args = parse_args(argv) |
| 146 | conditions = [Condition(str_cond) for str_cond in args.conditions] |
| 147 | consolidated_json = platform_json.load_consolidated_json(args.consolidated) |
| 148 | |
| 149 | outputs = [] |
| 150 | for platform in consolidated_json: |
| 151 | # Determine whether the platform satisfies all conditions. |
| 152 | platform_config = platform_json.calculate_config(platform, None, |
| 153 | consolidated_json) |
| 154 | platform_satisfied = all_satisfied(conditions, platform_config) |
| 155 | |
| 156 | # Check whether any of the platform's models are exceptions. |
| 157 | models = consolidated_json[platform].get('models', {}) |
| 158 | exceptions = [] |
| 159 | for model in models: |
| 160 | model_config = platform_json.calculate_config(platform, model, |
| 161 | consolidated_json) |
| 162 | if all_satisfied(conditions, model_config) != platform_satisfied: |
| 163 | exceptions.append(model) |
| 164 | |
| 165 | # Print if the platform or any models satisfy the conditions. |
| 166 | if platform_satisfied and exceptions: |
| 167 | outputs.append('%s (except %s)' % (platform, ', '.join(exceptions))) |
| 168 | elif exceptions: |
| 169 | outputs.append('%s (only %s)' % (platform, ', '.join(exceptions))) |
| 170 | elif platform_satisfied: |
| 171 | outputs.append(platform) |
| 172 | |
| 173 | if outputs: |
| 174 | print('\n'.join(outputs)) |
| 175 | else: |
| 176 | print('No platforms matched.') |
| 177 | |
| 178 | |
| 179 | if __name__ == '__main__': |
| 180 | main(sys.argv[1:]) |