1# Copyright 2015 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Presubmit script validating field trial configs. 5 6See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts 7for more details on the presubmit API built into depot_tools. 8""" 9 10import copy 11import io 12import json 13import re 14import sys 15 16from collections import OrderedDict 17 18VALID_EXPERIMENT_KEYS = [ 19 'name', 'forcing_flag', 'params', 'enable_features', 'disable_features', 20 'min_os_version', 'hardware_classes', 'exclude_hardware_classes', '//0', 21 '//1', '//2', '//3', '//4', '//5', '//6', '//7', '//8', '//9' 22] 23 24FIELDTRIAL_CONFIG_FILE_NAME = 'fieldtrial_testing_config.json' 25 26BASE_FEATURE_PATTERN = r"BASE_FEATURE\((.*?),(.*?),(.*?)\);" 27BASE_FEATURE_RE = re.compile(BASE_FEATURE_PATTERN, flags=re.MULTILINE+re.DOTALL) 28 29def PrettyPrint(contents): 30 """Pretty prints a fieldtrial configuration. 31 32 Args: 33 contents: File contents as a string. 34 35 Returns: 36 Pretty printed file contents. 37 """ 38 39 # We have a preferred ordering of the fields (e.g. platforms on top). This 40 # code loads everything into OrderedDicts and then tells json to dump it out. 41 # The JSON dumper will respect the dict ordering. 42 # 43 # The ordering is as follows: 44 # { 45 # 'StudyName Alphabetical': [ 46 # { 47 # 'platforms': [sorted platforms] 48 # 'groups': [ 49 # { 50 # name: ... 51 # forcing_flag: "forcing flag string" 52 # params: {sorted dict} 53 # enable_features: [sorted features] 54 # disable_features: [sorted features] 55 # min_os_version: "version string" 56 # hardware_classes: [sorted classes] 57 # exclude_hardware_classes: [sorted classes] 58 # (Unexpected extra keys will be caught by the validator) 59 # } 60 # ], 61 # .... 62 # }, 63 # ... 64 # ] 65 # ... 66 # } 67 config = json.loads(contents) 68 ordered_config = OrderedDict() 69 for key in sorted(config.keys()): 70 study = copy.deepcopy(config[key]) 71 ordered_study = [] 72 for experiment_config in study: 73 ordered_experiment_config = OrderedDict([('platforms', 74 experiment_config['platforms']), 75 ('experiments', [])]) 76 for experiment in experiment_config['experiments']: 77 ordered_experiment = OrderedDict() 78 for index in range(0, 10): 79 comment_key = '//' + str(index) 80 if comment_key in experiment: 81 ordered_experiment[comment_key] = experiment[comment_key] 82 ordered_experiment['name'] = experiment['name'] 83 if 'forcing_flag' in experiment: 84 ordered_experiment['forcing_flag'] = experiment['forcing_flag'] 85 if 'params' in experiment: 86 ordered_experiment['params'] = OrderedDict( 87 sorted(experiment['params'].items(), key=lambda t: t[0])) 88 if 'enable_features' in experiment: 89 ordered_experiment['enable_features'] = \ 90 sorted(experiment['enable_features']) 91 if 'disable_features' in experiment: 92 ordered_experiment['disable_features'] = \ 93 sorted(experiment['disable_features']) 94 if 'min_os_version' in experiment: 95 ordered_experiment['min_os_version'] = experiment['min_os_version'] 96 if 'hardware_classes' in experiment: 97 ordered_experiment['hardware_classes'] = \ 98 sorted(experiment['hardware_classes']) 99 if 'exclude_hardware_classes' in experiment: 100 ordered_experiment['exclude_hardware_classes'] = \ 101 sorted(experiment['exclude_hardware_classes']) 102 ordered_experiment_config['experiments'].append(ordered_experiment) 103 ordered_study.append(ordered_experiment_config) 104 ordered_config[key] = ordered_study 105 return json.dumps( 106 ordered_config, sort_keys=False, indent=4, separators=(',', ': ')) + '\n' 107 108 109def ValidateData(json_data, file_path, message_type): 110 """Validates the format of a fieldtrial configuration. 111 112 Args: 113 json_data: Parsed JSON object representing the fieldtrial config. 114 file_path: String representing the path to the JSON file. 115 message_type: Type of message from |output_api| to return in the case of 116 errors/warnings. 117 118 Returns: 119 A list of |message_type| messages. In the case of all tests passing with no 120 warnings/errors, this will return []. 121 """ 122 123 def _CreateMessage(message_format, *args): 124 return _CreateMalformedConfigMessage(message_type, file_path, 125 message_format, *args) 126 127 if not isinstance(json_data, dict): 128 return _CreateMessage('Expecting dict') 129 for (study, experiment_configs) in iter(json_data.items()): 130 warnings = _ValidateEntry(study, experiment_configs, _CreateMessage) 131 if warnings: 132 return warnings 133 134 return [] 135 136 137def _ValidateEntry(study, experiment_configs, create_message_fn): 138 """Validates one entry of the field trial configuration.""" 139 if not isinstance(study, str): 140 return create_message_fn('Expecting keys to be string, got %s', type(study)) 141 if not isinstance(experiment_configs, list): 142 return create_message_fn('Expecting list for study %s', study) 143 144 # Add context to other messages. 145 def _CreateStudyMessage(message_format, *args): 146 suffix = ' in Study[%s]' % study 147 return create_message_fn(message_format + suffix, *args) 148 149 for experiment_config in experiment_configs: 150 warnings = _ValidateExperimentConfig(experiment_config, _CreateStudyMessage) 151 if warnings: 152 return warnings 153 return [] 154 155 156def _ValidateExperimentConfig(experiment_config, create_message_fn): 157 """Validates one config in a configuration entry.""" 158 if not isinstance(experiment_config, dict): 159 return create_message_fn('Expecting dict for experiment config') 160 if not 'experiments' in experiment_config: 161 return create_message_fn('Missing valid experiments for experiment config') 162 if not isinstance(experiment_config['experiments'], list): 163 return create_message_fn('Expecting list for experiments') 164 for experiment_group in experiment_config['experiments']: 165 warnings = _ValidateExperimentGroup(experiment_group, create_message_fn) 166 if warnings: 167 return warnings 168 if not 'platforms' in experiment_config: 169 return create_message_fn('Missing valid platforms for experiment config') 170 if not isinstance(experiment_config['platforms'], list): 171 return create_message_fn('Expecting list for platforms') 172 supported_platforms = [ 173 'android', 'android_weblayer', 'android_webview', 'chromeos', 174 'chromeos_lacros', 'fuchsia', 'ios', 'linux', 'mac', 'windows' 175 ] 176 experiment_platforms = experiment_config['platforms'] 177 unsupported_platforms = list( 178 set(experiment_platforms).difference(supported_platforms)) 179 if unsupported_platforms: 180 return create_message_fn('Unsupported platforms %s', unsupported_platforms) 181 return [] 182 183 184def _ValidateExperimentGroup(experiment_group, create_message_fn): 185 """Validates one group of one config in a configuration entry.""" 186 name = experiment_group.get('name', '') 187 if not name or not isinstance(name, str): 188 return create_message_fn('Missing valid name for experiment') 189 190 # Add context to other messages. 191 def _CreateGroupMessage(message_format, *args): 192 suffix = ' in Group[%s]' % name 193 return create_message_fn(message_format + suffix, *args) 194 195 if 'params' in experiment_group: 196 params = experiment_group['params'] 197 if not isinstance(params, dict): 198 return _CreateGroupMessage('Expected dict for params') 199 for (key, value) in iter(params.items()): 200 if not isinstance(key, str) or not isinstance(value, str): 201 return _CreateGroupMessage('Invalid param (%s: %s)', key, value) 202 for key in experiment_group.keys(): 203 if key not in VALID_EXPERIMENT_KEYS: 204 return _CreateGroupMessage('Key[%s] is not a valid key', key) 205 return [] 206 207 208def _CreateMalformedConfigMessage(message_type, file_path, message_format, 209 *args): 210 """Returns a list containing one |message_type| with the error message. 211 212 Args: 213 message_type: Type of message from |output_api| to return in the case of 214 errors/warnings. 215 message_format: The error message format string. 216 file_path: The path to the config file. 217 *args: The args for message_format. 218 219 Returns: 220 A list containing a message_type with a formatted error message and 221 'Malformed config file [file]: ' prepended to it. 222 """ 223 error_message_format = 'Malformed config file %s: ' + message_format 224 format_args = (file_path,) + args 225 return [message_type(error_message_format % format_args)] 226 227 228def CheckPretty(contents, file_path, message_type): 229 """Validates the pretty printing of fieldtrial configuration. 230 231 Args: 232 contents: File contents as a string. 233 file_path: String representing the path to the JSON file. 234 message_type: Type of message from |output_api| to return in the case of 235 errors/warnings. 236 237 Returns: 238 A list of |message_type| messages. In the case of all tests passing with no 239 warnings/errors, this will return []. 240 """ 241 pretty = PrettyPrint(contents) 242 if contents != pretty: 243 return [ 244 message_type('Pretty printing error: Run ' 245 'python3 testing/variations/PRESUBMIT.py %s' % file_path) 246 ] 247 return [] 248 249def _GetStudyConfigFeatures(study_config): 250 """Gets the set of features overridden in a study config.""" 251 features = set() 252 for experiment in study_config.get("experiments", []): 253 features.update(experiment.get("enable_features", [])) 254 features.update(experiment.get("disable_features", [])) 255 return features 256 257def _GetDuplicatedFeatures(study1, study2): 258 """Gets the set of features that are overridden in two overlapping studies.""" 259 duplicated_features = set() 260 for study_config1 in study1: 261 features = _GetStudyConfigFeatures(study_config1) 262 platforms = set(study_config1.get("platforms", [])) 263 for study_config2 in study2: 264 # If the study configs do not specify any common platform, they do not 265 # overlap, so we can skip them. 266 if platforms.isdisjoint(set(study_config2.get("platforms", []))): 267 continue 268 269 common_features = features & _GetStudyConfigFeatures(study_config2) 270 duplicated_features.update(common_features) 271 272 return duplicated_features 273 274def CheckDuplicatedFeatures(new_json_data, old_json_data, message_type): 275 """Validates that features are not specified in multiple studies. 276 277 Note that a feature may be specified in different studies that do not overlap. 278 For example, if they specify different platforms. In such a case, this will 279 not give a warning/error. However, it is possible that this incorrectly 280 gives an error, as it is possible for studies to have complex filters (e.g., 281 if they make use of additional filters such as form_factors, 282 is_low_end_device, etc.). In those cases, the PRESUBMIT check can be bypassed. 283 Since this will only check for studies that were changed in this particular 284 commit, bypassing the PRESUBMIT check will not block future commits. 285 286 Args: 287 new_json_data: Parsed JSON object representing the new fieldtrial config. 288 old_json_data: Parsed JSON object representing the old fieldtrial config. 289 message_type: Type of message from |output_api| to return in the case of 290 errors/warnings. 291 292 Returns: 293 A list of |message_type| messages. In the case of all tests passing with no 294 warnings/errors, this will return []. 295 """ 296 # Get list of studies that changed. 297 changed_studies = [] 298 for study_name in new_json_data: 299 if (study_name not in old_json_data or 300 new_json_data[study_name] != old_json_data[study_name]): 301 changed_studies.append(study_name) 302 303 # A map between a feature name and the name of studies that use it. E.g., 304 # duplicated_features_to_studies_map["FeatureA"] = {"StudyA", "StudyB"}. 305 # Only features that are defined in multiple studies are added to this map. 306 duplicated_features_to_studies_map = dict() 307 308 # Compare the changed studies against all studies defined. 309 for changed_study_name in changed_studies: 310 for study_name in new_json_data: 311 if changed_study_name == study_name: 312 continue 313 314 duplicated_features = _GetDuplicatedFeatures( 315 new_json_data[changed_study_name], new_json_data[study_name]) 316 317 for feature in duplicated_features: 318 if feature not in duplicated_features_to_studies_map: 319 duplicated_features_to_studies_map[feature] = set() 320 duplicated_features_to_studies_map[feature].update( 321 [changed_study_name, study_name]) 322 323 if len(duplicated_features_to_studies_map) == 0: 324 return [] 325 326 duplicated_features_strings = [ 327 "%s (in studies %s)" % (feature, ', '.join(studies)) 328 for feature, studies in duplicated_features_to_studies_map.items() 329 ] 330 331 return [ 332 message_type('The following feature(s) were specified in multiple ' 333 'studies: %s' % ', '.join(duplicated_features_strings)) 334 ] 335 336 337def CheckUndeclaredFeatures(input_api, output_api, json_data, changed_lines): 338 """Checks that feature names are all valid declared features. 339 340 There have been more than one instance of developers accidentally mistyping 341 a feature name in the fieldtrial_testing_config.json file, which leads 342 to the config silently doing nothing. 343 344 This check aims to catch these errors by validating that the feature name 345 is defined somewhere in the Chrome source code. 346 347 Args: 348 input_api: Presubmit InputApi 349 output_api: Presubmit OutputApi 350 json_data: The parsed fieldtrial_testing_config.json 351 changed_lines: The AffectedFile.ChangedContents() of the json file 352 353 Returns: 354 List of validation messages - empty if there are no errors. 355 """ 356 357 declared_features = set() 358 # I was unable to figure out how to do a proper top-level include that did 359 # not depend on getting the path from input_api. I found this pattern 360 # elsewhere in the code base. Please change to a top-level include if you 361 # know how. 362 old_sys_path = sys.path[:] 363 try: 364 sys.path.append(input_api.os_path.join( 365 input_api.PresubmitLocalPath(), 'presubmit')) 366 # pylint: disable=import-outside-toplevel 367 import find_features 368 # pylint: enable=import-outside-toplevel 369 declared_features = find_features.FindDeclaredFeatures(input_api) 370 finally: 371 sys.path = old_sys_path 372 373 if not declared_features: 374 return [message_type("Presubmit unable to find any declared flags " 375 "in source. Please check PRESUBMIT.py for errors.")] 376 377 messages = [] 378 # Join all changed lines into a single string. This will be used to check 379 # if feature names are present in the changed lines by substring search. 380 changed_contents = " ".join([x[1].strip() for x in changed_lines]) 381 for study_name in json_data: 382 study = json_data[study_name] 383 for config in study: 384 features = set(_GetStudyConfigFeatures(config)) 385 # Determine if a study has been touched by the current change by checking 386 # if any of the features are part of the changed lines of the file. 387 # This limits the noise from old configs that are no longer valid. 388 probably_affected = False 389 for feature in features: 390 if feature in changed_contents: 391 probably_affected = True 392 break 393 394 if probably_affected and not declared_features.issuperset(features): 395 missing_features = features - declared_features 396 # CrOS has external feature declarations starting with this prefix 397 # (checked by build tools in base/BUILD.gn). 398 # Warn, but don't break, if they are present in the CL 399 cros_late_boot_features = {s for s in missing_features if 400 s.startswith("CrOSLateBoot")} 401 missing_features = missing_features - cros_late_boot_features 402 if cros_late_boot_features: 403 msg = ("CrOSLateBoot features added to " 404 "study %s are not checked by presubmit." 405 "\nPlease manually check that they exist in the code base." 406 ) % study_name 407 messages.append(output_api.PresubmitResult(msg, 408 cros_late_boot_features)) 409 410 if missing_features: 411 msg = ("Presubmit was unable to verify existence of features in " 412 "study %s.\nThis happens most commonly if the feature is " 413 "defined by code generation.\n" 414 "Please verify that the feature names have been spelled " 415 "correctly before submitting. The affected features are:" 416 ) % study_name 417 messages.append(output_api.PresubmitResult(msg, missing_features)) 418 419 return messages 420 421 422def CommonChecks(input_api, output_api): 423 affected_files = input_api.AffectedFiles( 424 include_deletes=False, 425 file_filter=lambda x: x.LocalPath().endswith('.json')) 426 for f in affected_files: 427 if not f.LocalPath().endswith(FIELDTRIAL_CONFIG_FILE_NAME): 428 return [ 429 output_api.PresubmitError( 430 '%s is the only json file expected in this folder. If new jsons ' 431 'are added, please update the presubmit process with proper ' 432 'validation. ' % FIELDTRIAL_CONFIG_FILE_NAME 433 ) 434 ] 435 contents = input_api.ReadFile(f) 436 try: 437 json_data = input_api.json.loads(contents) 438 result = ValidateData( 439 json_data, 440 f.AbsoluteLocalPath(), 441 output_api.PresubmitError) 442 if result: 443 return result 444 result = CheckPretty(contents, f.LocalPath(), output_api.PresubmitError) 445 if result: 446 return result 447 result = CheckDuplicatedFeatures( 448 json_data, 449 input_api.json.loads('\n'.join(f.OldContents())), 450 output_api.PresubmitError) 451 if result: 452 return result 453 result = CheckUndeclaredFeatures(input_api, output_api, json_data, 454 f.ChangedContents()) 455 if result: 456 return result 457 except ValueError: 458 return [ 459 output_api.PresubmitError('Malformed JSON file: %s' % f.LocalPath()) 460 ] 461 return [] 462 463 464def CheckChangeOnUpload(input_api, output_api): 465 return CommonChecks(input_api, output_api) 466 467 468def CheckChangeOnCommit(input_api, output_api): 469 return CommonChecks(input_api, output_api) 470 471 472def main(argv): 473 with io.open(argv[1], encoding='utf-8') as f: 474 content = f.read() 475 pretty = PrettyPrint(content) 476 io.open(argv[1], 'wb').write(pretty.encode('utf-8')) 477 478 479if __name__ == '__main__': 480 sys.exit(main(sys.argv)) 481