xref: /aosp_15_r20/external/cronet/testing/variations/PRESUBMIT.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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