Fix emulator build error
[platform/framework/web/chromium-efl.git] / testing / variations / PRESUBMIT.py
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
6 See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
7 for more details on the presubmit API built into depot_tools.
8 """
9
10 import copy
11 import io
12 import json
13 import re
14 import sys
15
16 from collections import OrderedDict
17
18 VALID_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
24 FIELDTRIAL_CONFIG_FILE_NAME = 'fieldtrial_testing_config.json'
25
26 BASE_FEATURE_PATTERN = r"BASE_FEATURE\((.*?),(.*?),(.*?)\);"
27 BASE_FEATURE_RE = re.compile(BASE_FEATURE_PATTERN, flags=re.MULTILINE+re.DOTALL)
28
29 def 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
100 def 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
128 def _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
147 def _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
175 def _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
199 def _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
219 def 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
240 def _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
248 def _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
265 def 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
328 def 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
413 def 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
455 def CheckChangeOnUpload(input_api, output_api):
456   return CommonChecks(input_api, output_api)
457
458
459 def CheckChangeOnCommit(input_api, output_api):
460   return CommonChecks(input_api, output_api)
461
462
463 def 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
470 if __name__ == '__main__':
471   sys.exit(main(sys.argv))