123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- # Copyright 2019 The Chromium Authors. All rights reserved.
- # Use of this source code is governed by a BSD-style license that can be
- # found in the LICENSE file.
- """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())
|