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