| # Copyright 2019 The Chromium Authors | 
 | # Use of this source code is governed by a BSD-style license that can be | 
 | # found in the LICENSE file. | 
 |  | 
 | r"""A script to split Chrome variations into two sets. | 
 |  | 
 | Chrome runs with many experiments and variations (field trials) that are | 
 | randomly selected based on a configuration from a server. They lead to | 
 | different code paths and different Chrome behaviors. When a bug is caused by | 
 | one of the experiments or variations, it is useful to be able to bisect into | 
 | the set and pin-point which one is responsible. | 
 |  | 
 | Go to chrome://version/?show-variations-cmd, at the bottom a few commandline | 
 | switches define the current experiments and variations Chrome runs with. | 
 |  | 
 | Sample use: | 
 |  | 
 | python split_variations_cmd.py --file="variations_cmd.txt" --output-dir=".\out" | 
 |  | 
 | "variations_cmd.txt" is the command line switches data saved from | 
 | chrome://version/?show-variations-cmd. This command splits them into two sets. | 
 | If needed, the script can run on one set to further divide until a single | 
 | experiment/variation is pin-pointed as responsible. | 
 |  | 
 | Note that on Windows, directly passing the command line switches taken from | 
 | chrome://version/?show-variations-cmd to Chrome in "Command Prompt" won't work. | 
 | This is because Chrome in "Command Prompt" doesn't seem to handle | 
 | --force-fieldtrials="value"; it only handles --force-fieldtrials=value. | 
 | Run Chrome through "Windows PowerShell" instead. | 
 | """ | 
 |  | 
 | try: | 
 |   from urllib.parse import unquote | 
 | except ImportError: | 
 |   # ToDo(crbug/1287214): Remove Exception case upon full migration to Python 3 | 
 |   from urllib import unquote | 
 |  | 
 | import collections | 
 | import os | 
 | import optparse | 
 | import sys | 
 | import urllib | 
 |  | 
 | _ENABLE_FEATURES_SWITCH_NAME = 'enable-features' | 
 | _DISABLE_FEATURES_SWITCH_NAME = 'disable-features' | 
 | _FORCE_FIELD_TRIALS_SWITCH_NAME = 'force-fieldtrials' | 
 | _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME = 'force-fieldtrial-params' | 
 |  | 
 |  | 
 | _Trial = collections.namedtuple('Trial', ['star', 'trial_name', 'group_name']) | 
 | _Param = collections.namedtuple('Param', ['key', 'value']) | 
 | _TrialParams = collections.namedtuple('TrialParams', | 
 |                                       ['trial_name', 'group_name', 'params']) | 
 | _Feature = collections.namedtuple('Feature', ['star', 'key', 'value']) | 
 |  | 
 | def _ParseForceFieldTrials(data): | 
 |   """Parses --force-fieldtrials switch value string. | 
 |  | 
 |   The switch value string is parsed to a list of _Trial objects. | 
 |   """ | 
 |   data = data.rstrip('/') | 
 |   items = data.split('/') | 
 |   if len(items) % 2 != 0: | 
 |     raise ValueError('odd number of items in --force-fieldtrials value') | 
 |   trial_names = items[0::2] | 
 |   group_names = items[1::2] | 
 |   results = [] | 
 |   for trial_name, group_name in zip(trial_names, group_names): | 
 |     star = False | 
 |     if trial_name.startswith('*'): | 
 |       star = True | 
 |       trial_name = trial_name[1:] | 
 |     results.append(_Trial(star, trial_name, group_name)) | 
 |   return results | 
 |  | 
 |  | 
 | def _BuildForceFieldTrialsSwitchValue(trials): | 
 |   """Generates --force-fieldtrials switch value string. | 
 |  | 
 |   This is the opposite of _ParseForceFieldTrials(). | 
 |  | 
 |   Args: | 
 |       trials: A list of _Trial objects from which the switch value string | 
 |           is generated. | 
 |   """ | 
 |   return ''.join('%s%s/%s/' % ( | 
 |     '*' if trial.star else '', | 
 |     trial.trial_name, | 
 |     trial.group_name | 
 |   ) for trial in trials) | 
 |  | 
 |  | 
 | def _ParseForceFieldTrialParams(data): | 
 |   """Parses --force-fieldtrial-params switch value string. | 
 |  | 
 |   The switch value string is parsed to a list of _TrialParams objects. | 
 |  | 
 |   Format is trial_name.group_name:param0/value0/.../paramN/valueN. | 
 |   """ | 
 |   items = data.split(',') | 
 |   results = [] | 
 |   for item in items: | 
 |     tokens = item.split(':') | 
 |     if len(tokens) != 2: | 
 |       raise ValueError('Wrong format, expected trial_name.group_name:' | 
 |                        'p0/v0/.../pN/vN, got %s' % item) | 
 |     trial_group = tokens[0].split('.') | 
 |     if len(trial_group) != 2: | 
 |       raise ValueError('Wrong format, expected trial_name.group_name, ' | 
 |                        'got %s' % tokens[0]) | 
 |     trial_name = trial_group[0] | 
 |     if len(trial_name) == 0 or trial_name[0] == '*': | 
 |       raise ValueError('Wrong field trail params format: %s' % item) | 
 |     group_name = trial_group[1] | 
 |     params = tokens[1].split('/') | 
 |     if len(params) < 2 or len(params) % 2 != 0: | 
 |       raise ValueError('Field trial params should be param/value pairs %s' % | 
 |                        tokens[1]) | 
 |     pairs = [ | 
 |       _Param(key=params[i], value=params[i + 1]) | 
 |       for i in range(0, len(params), 2) | 
 |     ] | 
 |     results.append(_TrialParams(trial_name, group_name, pairs)) | 
 |   return results | 
 |  | 
 |  | 
 | def _BuildForceFieldTrialParamsSwitchValue(trials): | 
 |   """Generates --force-fieldtrial-params switch value string. | 
 |  | 
 |   This is the opposite of _ParseForceFieldTrialParams(). | 
 |  | 
 |   Args: | 
 |       trials: A list of _TrialParams objects from which the switch value | 
 |           string is generated. | 
 |   """ | 
 |   return ','.join('%s.%s:%s' % ( | 
 |     trial.trial_name, | 
 |     trial.group_name, | 
 |     '/'.join('%s/%s' % (param.key, param.value) for param in trial.params), | 
 |   ) for trial in trials) | 
 |  | 
 |  | 
 | def _ValidateForceFieldTrialsAndParams(trials, params): | 
 |   """Checks if all params have corresponding field trials specified. | 
 |  | 
 |   |trials| comes from --force-fieldtrials switch, |params| comes from | 
 |   --force-fieldtrial-params switch. | 
 |   """ | 
 |   if len(params) > len(trials): | 
 |     raise ValueError("params size (%d) larger than trials size (%d)" % | 
 |                      (len(params), len(trials))) | 
 |   trial_groups = {trial.trial_name: trial.group_name for trial in trials} | 
 |   for param in params: | 
 |     trial_name = unquote(param.trial_name) | 
 |     group_name = unquote(param.group_name) | 
 |     if trial_name not in trial_groups: | 
 |       raise ValueError("Fail to find trial_name %s in trials" % trial_name) | 
 |     if group_name != trial_groups[trial_name]: | 
 |       raise ValueError("group_name mismatch for trial_name %s, %s vs %s" % | 
 |                        (trial_name, group_name, trial_groups[trial_name])) | 
 |  | 
 |  | 
 | def _SplitFieldTrials(trials, trial_params): | 
 |   """Splits (--force-fieldtrials, --force-fieldtrial-params) pair to two pairs. | 
 |  | 
 |   Note that any list in the output pairs could be empty, depending on the | 
 |   number of elements in the input lists. | 
 |   """ | 
 |   middle = (len(trials) + 1) // 2 | 
 |   params = {unquote(trial.trial_name): trial for trial in trial_params} | 
 |  | 
 |   trials_first = trials[:middle] | 
 |   params_first = [] | 
 |   for trial in trials_first: | 
 |     if trial.trial_name in params: | 
 |       params_first.append(params[trial.trial_name]) | 
 |  | 
 |   trials_second = trials[middle:] | 
 |   params_second = [] | 
 |   for trial in trials_second: | 
 |     if trial.trial_name in params: | 
 |       params_second.append(params[trial.trial_name]) | 
 |  | 
 |   return [ | 
 |     {_FORCE_FIELD_TRIALS_SWITCH_NAME: trials_first, | 
 |      _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME: params_first}, | 
 |     {_FORCE_FIELD_TRIALS_SWITCH_NAME: trials_second, | 
 |      _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME: params_second}, | 
 |   ] | 
 |  | 
 |  | 
 | def _ParseFeatureListString(data, is_disable): | 
 |   """Parses --enable/--disable-features switch value string. | 
 |  | 
 |   The switch value string is parsed to a list of _Feature objects. | 
 |  | 
 |   Args: | 
 |       data: --enable-features or --disable-features switch value string. | 
 |       is_disable: Parses --enable-features switch value string if True; | 
 |           --disable-features switch value string if False. | 
 |   """ | 
 |   items = data.split(',') | 
 |   results = [] | 
 |   for item in items: | 
 |     pair = item.split('<', 1) | 
 |     feature = pair[0] | 
 |     value = None | 
 |     if len(pair) > 1: | 
 |       value = pair[1] | 
 |     star = feature.startswith('*') | 
 |     if star: | 
 |       if is_disable: | 
 |         raise ValueError('--disable-features should not mark a feature with *') | 
 |       feature = feature[1:] | 
 |     results.append(_Feature(star, feature, value)) | 
 |   return results | 
 |  | 
 |  | 
 | def _BuildFeaturesSwitchValue(features): | 
 |   """Generates --enable/--disable-features switch value string. | 
 |  | 
 |   This function does the opposite of _ParseFeatureListString(). | 
 |  | 
 |   Args: | 
 |       features: A list of _Feature objects from which the switch value string | 
 |           is generated. | 
 |   """ | 
 |   return ','.join('%s%s%s' % ( | 
 |     '*' if feature.star else '', | 
 |     feature.key, | 
 |     '<%s' % feature.value if feature.value is not None else '', | 
 |   ) for feature in features) | 
 |  | 
 |  | 
 | def _SplitFeatures(features): | 
 |   """Splits a list of _Features objects into two lists. | 
 |  | 
 |   Note that either returned list could be empty, depending on the number of | 
 |   elements in the input list. | 
 |   """ | 
 |   # Split a list of size N into two list: one of size middle, the other of size | 
 |   # N - middle. This works even when N is 0 or 1, resulting in empty list(s). | 
 |   middle = (len(features) + 1) // 2 | 
 |   return features[:middle], features[middle:] | 
 |  | 
 |  | 
 | def ParseCommandLineSwitchesString(data): | 
 |   """Parses command line switches string into a dictionary. | 
 |  | 
 |   Format: { switch1:value1, switch2:value2, ..., switchN:valueN }. | 
 |   """ | 
 |   switches = data.split('--') | 
 |   # The first one is always an empty string before the first '--'. | 
 |   switches = switches[1:] | 
 |   switch_data = {} | 
 |   for switch in switches: | 
 |     switch = switch.strip() | 
 |     fields = switch.split('=', 1) # Split by the first '='. | 
 |     if len(fields) != 2: | 
 |       raise ValueError('Wrong format, expected name=value, got %s' % switch) | 
 |     switch_name, switch_value = fields | 
 |     if switch_value[0] == '"' and switch_value[-1] == '"': | 
 |       switch_value = switch_value[1:-1] | 
 |     if (switch_name == _FORCE_FIELD_TRIALS_SWITCH_NAME and | 
 |         switch_value[-1] != '/'): | 
 |       # Older versions of Chrome do not include '/' in the end, but newer | 
 |       # versions do. | 
 |       # TODO(zmo): Verify if '/' is included in the end, older versions of | 
 |       # Chrome can still accept such switch. | 
 |       switch_value = switch_value + '/' | 
 |     switch_data[switch_name] = switch_value | 
 |   return switch_data | 
 |  | 
 |  | 
 | def ParseVariationsCmdFromString(input_string): | 
 |   """Parses commandline switches string into internal representation. | 
 |  | 
 |   Commandline switches string comes from chrome://version/?show-variations-cmd. | 
 |   Currently we parse the following four command line switches: | 
 |     --force-fieldtrials | 
 |     --force-fieldtrial-params | 
 |     --enable-features | 
 |     --disable-features | 
 |   """ | 
 |   switch_data = ParseCommandLineSwitchesString(input_string) | 
 |   results = {} | 
 |   for switch_name, switch_value in switch_data.items(): | 
 |     built_switch_value = None | 
 |     if switch_name == _FORCE_FIELD_TRIALS_SWITCH_NAME: | 
 |       results[switch_name] = _ParseForceFieldTrials(switch_value) | 
 |       built_switch_value = _BuildForceFieldTrialsSwitchValue( | 
 |           results[switch_name]) | 
 |     elif switch_name == _DISABLE_FEATURES_SWITCH_NAME: | 
 |       results[switch_name] = _ParseFeatureListString( | 
 |           switch_value, is_disable=True) | 
 |       built_switch_value = _BuildFeaturesSwitchValue(results[switch_name]) | 
 |     elif switch_name == _ENABLE_FEATURES_SWITCH_NAME: | 
 |       results[switch_name] = _ParseFeatureListString( | 
 |           switch_value, is_disable=False) | 
 |       built_switch_value = _BuildFeaturesSwitchValue(results[switch_name]) | 
 |     elif switch_name == _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME: | 
 |       results[switch_name] = _ParseForceFieldTrialParams(switch_value) | 
 |       built_switch_value = _BuildForceFieldTrialParamsSwitchValue( | 
 |           results[switch_name]) | 
 |     else: | 
 |       raise ValueError('Unexpected: --%s=%s', switch_name, switch_value) | 
 |     assert switch_value == built_switch_value | 
 |   if (_FORCE_FIELD_TRIALS_SWITCH_NAME in results and | 
 |       _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME in results): | 
 |     _ValidateForceFieldTrialsAndParams( | 
 |         results[_FORCE_FIELD_TRIALS_SWITCH_NAME], | 
 |         results[_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME]) | 
 |   return results | 
 |  | 
 |  | 
 | def ParseVariationsCmdFromFile(filename): | 
 |   """Parses commandline switches string into internal representation. | 
 |  | 
 |   Same as ParseVariationsCmdFromString(), except the commandline switches | 
 |   string comes from a file. | 
 |   """ | 
 |   with open(filename, 'r') as f: | 
 |     data = f.read().replace('\n', ' ') | 
 |   return ParseVariationsCmdFromString(data) | 
 |  | 
 |  | 
 | def VariationsCmdToStrings(data): | 
 |   """Converts a dictionary of {switch_name:switch_value} to a list of strings. | 
 |  | 
 |   Each string is in the format of '--switch_name=switch_value'. | 
 |  | 
 |   Args: | 
 |       data: Input data dictionary. Keys are four commandline switches: | 
 |         'force-fieldtrials' | 
 |         'force-fieldtrial-params' | 
 |         'enable-features' | 
 |         'disable-features' | 
 |  | 
 |   Returns: | 
 |       A list of strings. | 
 |   """ | 
 |   cmd_list = [] | 
 |   force_field_trials = data[_FORCE_FIELD_TRIALS_SWITCH_NAME] | 
 |   if len(force_field_trials) > 0: | 
 |     force_field_trials_switch_value = _BuildForceFieldTrialsSwitchValue( | 
 |         force_field_trials) | 
 |     cmd_list.append('--%s="%s"' % (_FORCE_FIELD_TRIALS_SWITCH_NAME, | 
 |                                    force_field_trials_switch_value)) | 
 |   force_field_trial_params = data[_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME] | 
 |   if len(force_field_trial_params) > 0: | 
 |     force_field_trial_params_switch_value = ( | 
 |         _BuildForceFieldTrialParamsSwitchValue(force_field_trial_params)) | 
 |     cmd_list.append('--%s="%s"' % (_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME, | 
 |                                    force_field_trial_params_switch_value)) | 
 |   enable_features = data[_ENABLE_FEATURES_SWITCH_NAME] | 
 |   if len(enable_features) > 0: | 
 |     enable_features_switch_value = _BuildFeaturesSwitchValue(enable_features) | 
 |     cmd_list.append('--%s="%s"' % (_ENABLE_FEATURES_SWITCH_NAME, | 
 |                                    enable_features_switch_value)) | 
 |   disable_features = data[_DISABLE_FEATURES_SWITCH_NAME] | 
 |   if len(disable_features) > 0: | 
 |     disable_features_switch_value = _BuildFeaturesSwitchValue( | 
 |         disable_features) | 
 |     cmd_list.append('--%s="%s"' % (_DISABLE_FEATURES_SWITCH_NAME, | 
 |                                    disable_features_switch_value)) | 
 |   return cmd_list | 
 |  | 
 |  | 
 | def SplitVariationsCmd(results): | 
 |   """Splits internal representation of commandline switches into two. | 
 |  | 
 |   This function can be called recursively when bisecting a set of experiments | 
 |   until one is identified to be responsble for a certain browser behavior. | 
 |  | 
 |   The commandline switches come from chrome://version/?show-variations-cmd. | 
 |   """ | 
 |   enable_features = results.get(_ENABLE_FEATURES_SWITCH_NAME, []) | 
 |   disable_features = results.get(_DISABLE_FEATURES_SWITCH_NAME, []) | 
 |   field_trials = results.get(_FORCE_FIELD_TRIALS_SWITCH_NAME, []) | 
 |   field_trial_params = results.get(_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME, []) | 
 |   enable_features_splits = _SplitFeatures(enable_features) | 
 |   disable_features_splits = _SplitFeatures(disable_features) | 
 |   field_trials_splits = _SplitFieldTrials(field_trials, field_trial_params) | 
 |   splits = [] | 
 |   for index in range(2): | 
 |     cmd_line = {} | 
 |     cmd_line.update(field_trials_splits[index]) | 
 |     cmd_line[_ENABLE_FEATURES_SWITCH_NAME] = enable_features_splits[index] | 
 |     cmd_line[_DISABLE_FEATURES_SWITCH_NAME] = disable_features_splits[index] | 
 |     splits.append(cmd_line) | 
 |   return splits | 
 |  | 
 |  | 
 | def SplitVariationsCmdFromString(input_string): | 
 |   """Splits commandline switches. | 
 |  | 
 |   This function can be called recursively when bisecting a set of experiments | 
 |   until one is identified to be responsble for a certain browser behavior. | 
 |  | 
 |   Same as SplitVariationsCmd(), except data comes from a string rather than | 
 |   an internal representation. | 
 |  | 
 |   Args: | 
 |       input_string: Variations string to be split. | 
 |  | 
 |   Returns: | 
 |       If input can be split, returns a list of two strings, each is half of | 
 |       the input variations cmd; otherwise, returns a list of one string. | 
 |   """ | 
 |   data = ParseVariationsCmdFromString(input_string) | 
 |   splits = SplitVariationsCmd(data) | 
 |   results = [] | 
 |   for split in splits: | 
 |     cmd_list = VariationsCmdToStrings(split) | 
 |     if cmd_list: | 
 |       results.append(' '.join(cmd_list)) | 
 |   return results | 
 |  | 
 |  | 
 | def SplitVariationsCmdFromFile(input_filename, output_dir=None): | 
 |   """Splits commandline switches. | 
 |  | 
 |   This function can be called recursively when bisecting a set of experiments | 
 |   until one is identified to be responsble for a certain browser behavior. | 
 |  | 
 |   Same as SplitVariationsCmd(), except data comes from a file rather than | 
 |   an internal representation. | 
 |  | 
 |   Args: | 
 |       input_filename: Variations file to be split. | 
 |       output_dir: Folder to output the split variations file(s). If None, | 
 |           output to the same folder as the input_filename. If the folder | 
 |           doesn't exist, it will be created. | 
 |  | 
 |   Returns: | 
 |       If input can be split, returns a list of two output filenames; | 
 |       otherwise, returns a list of one output filename. | 
 |   """ | 
 |   with open(input_filename, 'r') as f: | 
 |     input_string = f.read().replace('\n', ' ') | 
 |   splits = SplitVariationsCmdFromString(input_string) | 
 |   dirname, filename = os.path.split(input_filename) | 
 |   basename, ext = os.path.splitext(filename) | 
 |   if output_dir is None: | 
 |     output_dir = dirname | 
 |   if not os.path.exists(output_dir): | 
 |     os.makedirs(output_dir) | 
 |   split_filenames = [] | 
 |   for index in range(len(splits)): | 
 |     output_filename = "%s_%d%s" % (basename, index + 1, ext) | 
 |     output_filename = os.path.join(output_dir, output_filename) | 
 |     with open(output_filename, 'w') as output_file: | 
 |       output_file.write(splits[index]) | 
 |     split_filenames.append(output_filename) | 
 |   return split_filenames | 
 |  | 
 |  | 
 | def main(): | 
 |   parser = optparse.OptionParser() | 
 |   parser.add_option("-f", "--file", dest="filename", metavar="FILE", | 
 |                     help="specify a file with variations cmd for processing.") | 
 |   parser.add_option("--output-dir", dest="output_dir", | 
 |                     help="specify a folder where output files are saved. " | 
 |                     "If not specified, it is the folder of the input file.") | 
 |   options, _ = parser.parse_args() | 
 |   if not options.filename: | 
 |     parser.error("Input file is not specificed") | 
 |   SplitVariationsCmdFromFile(options.filename, options.output_dir) | 
 |   return 0 | 
 |  | 
 |  | 
 | if __name__ == '__main__': | 
 |   sys.exit(main()) |