split_variations_cmd.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. # Copyright 2019 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """A script to split Chrome variations into two sets.
  5. Chrome runs with many experiments and variations (field trials) that are
  6. randomly selected based on a configuration from a server. They lead to
  7. different code paths and different Chrome behaviors. When a bug is caused by
  8. one of the experiments or variations, it is useful to be able to bisect into
  9. the set and pin-point which one is responsible.
  10. Go to chrome://version/?show-variations-cmd, at the bottom a few commandline
  11. switches define the current experiments and variations Chrome runs with.
  12. Sample use:
  13. python split_variations_cmd.py --file="variations_cmd.txt" --output-dir=".\out"
  14. "variations_cmd.txt" is the command line switches data saved from
  15. chrome://version/?show-variations-cmd. This command splits them into two sets.
  16. If needed, the script can run on one set to further divide until a single
  17. experiment/variation is pin-pointed as responsible.
  18. Note that on Windows, directly passing the command line switches taken from
  19. chrome://version/?show-variations-cmd to Chrome in "Command Prompt" won't work.
  20. This is because Chrome in "Command Prompt" doesn't seem to handle
  21. --force-fieldtrials="value"; it only handles --force-fieldtrials=value.
  22. Run Chrome through "Windows PowerShell" instead.
  23. """
  24. try:
  25. from urllib.parse import unquote
  26. except ImportError:
  27. # ToDo(crbug/1287214): Remove Exception case upon full migration to Python 3
  28. from urllib import unquote
  29. import collections
  30. import os
  31. import optparse
  32. import sys
  33. import urllib
  34. _ENABLE_FEATURES_SWITCH_NAME = 'enable-features'
  35. _DISABLE_FEATURES_SWITCH_NAME = 'disable-features'
  36. _FORCE_FIELD_TRIALS_SWITCH_NAME = 'force-fieldtrials'
  37. _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME = 'force-fieldtrial-params'
  38. _Trial = collections.namedtuple('Trial', ['star', 'trial_name', 'group_name'])
  39. _Param = collections.namedtuple('Param', ['key', 'value'])
  40. _TrialParams = collections.namedtuple('TrialParams',
  41. ['trial_name', 'group_name', 'params'])
  42. _Feature = collections.namedtuple('Feature', ['star', 'key', 'value'])
  43. def _ParseForceFieldTrials(data):
  44. """Parses --force-fieldtrials switch value string.
  45. The switch value string is parsed to a list of _Trial objects.
  46. """
  47. data = data.rstrip('/')
  48. items = data.split('/')
  49. if len(items) % 2 != 0:
  50. raise ValueError('odd number of items in --force-fieldtrials value')
  51. trial_names = items[0::2]
  52. group_names = items[1::2]
  53. results = []
  54. for trial_name, group_name in zip(trial_names, group_names):
  55. star = False
  56. if trial_name.startswith('*'):
  57. star = True
  58. trial_name = trial_name[1:]
  59. results.append(_Trial(star, trial_name, group_name))
  60. return results
  61. def _BuildForceFieldTrialsSwitchValue(trials):
  62. """Generates --force-fieldtrials switch value string.
  63. This is the opposite of _ParseForceFieldTrials().
  64. Args:
  65. trials: A list of _Trial objects from which the switch value string
  66. is generated.
  67. """
  68. return ''.join('%s%s/%s/' % (
  69. '*' if trial.star else '',
  70. trial.trial_name,
  71. trial.group_name
  72. ) for trial in trials)
  73. def _ParseForceFieldTrialParams(data):
  74. """Parses --force-fieldtrial-params switch value string.
  75. The switch value string is parsed to a list of _TrialParams objects.
  76. Format is trial_name.group_name:param0/value0/.../paramN/valueN.
  77. """
  78. items = data.split(',')
  79. results = []
  80. for item in items:
  81. tokens = item.split(':')
  82. if len(tokens) != 2:
  83. raise ValueError('Wrong format, expected trial_name.group_name:'
  84. 'p0/v0/.../pN/vN, got %s' % item)
  85. trial_group = tokens[0].split('.')
  86. if len(trial_group) != 2:
  87. raise ValueError('Wrong format, expected trial_name.group_name, '
  88. 'got %s' % tokens[0])
  89. trial_name = trial_group[0]
  90. if len(trial_name) == 0 or trial_name[0] == '*':
  91. raise ValueError('Wrong field trail params format: %s' % item)
  92. group_name = trial_group[1]
  93. params = tokens[1].split('/')
  94. if len(params) < 2 or len(params) % 2 != 0:
  95. raise ValueError('Field trial params should be param/value pairs %s' %
  96. tokens[1])
  97. pairs = [
  98. _Param(key=params[i], value=params[i + 1])
  99. for i in range(0, len(params), 2)
  100. ]
  101. results.append(_TrialParams(trial_name, group_name, pairs))
  102. return results
  103. def _BuildForceFieldTrialParamsSwitchValue(trials):
  104. """Generates --force-fieldtrial-params switch value string.
  105. This is the opposite of _ParseForceFieldTrialParams().
  106. Args:
  107. trials: A list of _TrialParams objects from which the switch value
  108. string is generated.
  109. """
  110. return ','.join('%s.%s:%s' % (
  111. trial.trial_name,
  112. trial.group_name,
  113. '/'.join('%s/%s' % (param.key, param.value) for param in trial.params),
  114. ) for trial in trials)
  115. def _ValidateForceFieldTrialsAndParams(trials, params):
  116. """Checks if all params have corresponding field trials specified.
  117. |trials| comes from --force-fieldtrials switch, |params| comes from
  118. --force-fieldtrial-params switch.
  119. """
  120. if len(params) > len(trials):
  121. raise ValueError("params size (%d) larger than trials size (%d)" %
  122. (len(params), len(trials)))
  123. trial_groups = {trial.trial_name: trial.group_name for trial in trials}
  124. for param in params:
  125. trial_name = unquote(param.trial_name)
  126. group_name = unquote(param.group_name)
  127. if trial_name not in trial_groups:
  128. raise ValueError("Fail to find trial_name %s in trials" % trial_name)
  129. if group_name != trial_groups[trial_name]:
  130. raise ValueError("group_name mismatch for trial_name %s, %s vs %s" %
  131. (trial_name, group_name, trial_groups[trial_name]))
  132. def _SplitFieldTrials(trials, trial_params):
  133. """Splits (--force-fieldtrials, --force-fieldtrial-params) pair to two pairs.
  134. Note that any list in the output pairs could be empty, depending on the
  135. number of elements in the input lists.
  136. """
  137. middle = (len(trials) + 1) // 2
  138. params = {unquote(trial.trial_name): trial for trial in trial_params}
  139. trials_first = trials[:middle]
  140. params_first = []
  141. for trial in trials_first:
  142. if trial.trial_name in params:
  143. params_first.append(params[trial.trial_name])
  144. trials_second = trials[middle:]
  145. params_second = []
  146. for trial in trials_second:
  147. if trial.trial_name in params:
  148. params_second.append(params[trial.trial_name])
  149. return [
  150. {_FORCE_FIELD_TRIALS_SWITCH_NAME: trials_first,
  151. _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME: params_first},
  152. {_FORCE_FIELD_TRIALS_SWITCH_NAME: trials_second,
  153. _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME: params_second},
  154. ]
  155. def _ParseFeatureListString(data, is_disable):
  156. """Parses --enable/--disable-features switch value string.
  157. The switch value string is parsed to a list of _Feature objects.
  158. Args:
  159. data: --enable-features or --disable-features switch value string.
  160. is_disable: Parses --enable-features switch value string if True;
  161. --disable-features switch value string if False.
  162. """
  163. items = data.split(',')
  164. results = []
  165. for item in items:
  166. pair = item.split('<', 1)
  167. feature = pair[0]
  168. value = None
  169. if len(pair) > 1:
  170. value = pair[1]
  171. star = feature.startswith('*')
  172. if star:
  173. if is_disable:
  174. raise ValueError('--disable-features should not mark a feature with *')
  175. feature = feature[1:]
  176. results.append(_Feature(star, feature, value))
  177. return results
  178. def _BuildFeaturesSwitchValue(features):
  179. """Generates --enable/--disable-features switch value string.
  180. This function does the opposite of _ParseFeatureListString().
  181. Args:
  182. features: A list of _Feature objects from which the switch value string
  183. is generated.
  184. """
  185. return ','.join('%s%s%s' % (
  186. '*' if feature.star else '',
  187. feature.key,
  188. '<%s' % feature.value if feature.value is not None else '',
  189. ) for feature in features)
  190. def _SplitFeatures(features):
  191. """Splits a list of _Features objects into two lists.
  192. Note that either returned list could be empty, depending on the number of
  193. elements in the input list.
  194. """
  195. # Split a list of size N into two list: one of size middle, the other of size
  196. # N - middle. This works even when N is 0 or 1, resulting in empty list(s).
  197. middle = (len(features) + 1) // 2
  198. return features[:middle], features[middle:]
  199. def ParseCommandLineSwitchesString(data):
  200. """Parses command line switches string into a dictionary.
  201. Format: { switch1:value1, switch2:value2, ..., switchN:valueN }.
  202. """
  203. switches = data.split('--')
  204. # The first one is always an empty string before the first '--'.
  205. switches = switches[1:]
  206. switch_data = {}
  207. for switch in switches:
  208. switch = switch.strip()
  209. fields = switch.split('=', 1) # Split by the first '='.
  210. if len(fields) != 2:
  211. raise ValueError('Wrong format, expected name=value, got %s' % switch)
  212. switch_name, switch_value = fields
  213. if switch_value[0] == '"' and switch_value[-1] == '"':
  214. switch_value = switch_value[1:-1]
  215. if (switch_name == _FORCE_FIELD_TRIALS_SWITCH_NAME and
  216. switch_value[-1] != '/'):
  217. # Older versions of Chrome do not include '/' in the end, but newer
  218. # versions do.
  219. # TODO(zmo): Verify if '/' is included in the end, older versions of
  220. # Chrome can still accept such switch.
  221. switch_value = switch_value + '/'
  222. switch_data[switch_name] = switch_value
  223. return switch_data
  224. def ParseVariationsCmdFromString(input_string):
  225. """Parses commandline switches string into internal representation.
  226. Commandline switches string comes from chrome://version/?show-variations-cmd.
  227. Currently we parse the following four command line switches:
  228. --force-fieldtrials
  229. --force-fieldtrial-params
  230. --enable-features
  231. --disable-features
  232. """
  233. switch_data = ParseCommandLineSwitchesString(input_string)
  234. results = {}
  235. for switch_name, switch_value in switch_data.items():
  236. built_switch_value = None
  237. if switch_name == _FORCE_FIELD_TRIALS_SWITCH_NAME:
  238. results[switch_name] = _ParseForceFieldTrials(switch_value)
  239. built_switch_value = _BuildForceFieldTrialsSwitchValue(
  240. results[switch_name])
  241. elif switch_name == _DISABLE_FEATURES_SWITCH_NAME:
  242. results[switch_name] = _ParseFeatureListString(
  243. switch_value, is_disable=True)
  244. built_switch_value = _BuildFeaturesSwitchValue(results[switch_name])
  245. elif switch_name == _ENABLE_FEATURES_SWITCH_NAME:
  246. results[switch_name] = _ParseFeatureListString(
  247. switch_value, is_disable=False)
  248. built_switch_value = _BuildFeaturesSwitchValue(results[switch_name])
  249. elif switch_name == _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME:
  250. results[switch_name] = _ParseForceFieldTrialParams(switch_value)
  251. built_switch_value = _BuildForceFieldTrialParamsSwitchValue(
  252. results[switch_name])
  253. else:
  254. raise ValueError('Unexpected: --%s=%s', switch_name, switch_value)
  255. assert switch_value == built_switch_value
  256. if (_FORCE_FIELD_TRIALS_SWITCH_NAME in results and
  257. _FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME in results):
  258. _ValidateForceFieldTrialsAndParams(
  259. results[_FORCE_FIELD_TRIALS_SWITCH_NAME],
  260. results[_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME])
  261. return results
  262. def ParseVariationsCmdFromFile(filename):
  263. """Parses commandline switches string into internal representation.
  264. Same as ParseVariationsCmdFromString(), except the commandline switches
  265. string comes from a file.
  266. """
  267. with open(filename, 'r') as f:
  268. data = f.read().replace('\n', ' ')
  269. return ParseVariationsCmdFromString(data)
  270. def VariationsCmdToStrings(data):
  271. """Converts a dictionary of {switch_name:switch_value} to a list of strings.
  272. Each string is in the format of '--switch_name=switch_value'.
  273. Args:
  274. data: Input data dictionary. Keys are four commandline switches:
  275. 'force-fieldtrials'
  276. 'force-fieldtrial-params'
  277. 'enable-features'
  278. 'disable-features'
  279. Returns:
  280. A list of strings.
  281. """
  282. cmd_list = []
  283. force_field_trials = data[_FORCE_FIELD_TRIALS_SWITCH_NAME]
  284. if len(force_field_trials) > 0:
  285. force_field_trials_switch_value = _BuildForceFieldTrialsSwitchValue(
  286. force_field_trials)
  287. cmd_list.append('--%s="%s"' % (_FORCE_FIELD_TRIALS_SWITCH_NAME,
  288. force_field_trials_switch_value))
  289. force_field_trial_params = data[_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME]
  290. if len(force_field_trial_params) > 0:
  291. force_field_trial_params_switch_value = (
  292. _BuildForceFieldTrialParamsSwitchValue(force_field_trial_params))
  293. cmd_list.append('--%s="%s"' % (_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME,
  294. force_field_trial_params_switch_value))
  295. enable_features = data[_ENABLE_FEATURES_SWITCH_NAME]
  296. if len(enable_features) > 0:
  297. enable_features_switch_value = _BuildFeaturesSwitchValue(enable_features)
  298. cmd_list.append('--%s="%s"' % (_ENABLE_FEATURES_SWITCH_NAME,
  299. enable_features_switch_value))
  300. disable_features = data[_DISABLE_FEATURES_SWITCH_NAME]
  301. if len(disable_features) > 0:
  302. disable_features_switch_value = _BuildFeaturesSwitchValue(
  303. disable_features)
  304. cmd_list.append('--%s="%s"' % (_DISABLE_FEATURES_SWITCH_NAME,
  305. disable_features_switch_value))
  306. return cmd_list
  307. def SplitVariationsCmd(results):
  308. """Splits internal representation of commandline switches into two.
  309. This function can be called recursively when bisecting a set of experiments
  310. until one is identified to be responsble for a certain browser behavior.
  311. The commandline switches come from chrome://version/?show-variations-cmd.
  312. """
  313. enable_features = results.get(_ENABLE_FEATURES_SWITCH_NAME, [])
  314. disable_features = results.get(_DISABLE_FEATURES_SWITCH_NAME, [])
  315. field_trials = results.get(_FORCE_FIELD_TRIALS_SWITCH_NAME, [])
  316. field_trial_params = results.get(_FORCE_FIELD_TRIAL_PARAMS_SWITCH_NAME, [])
  317. enable_features_splits = _SplitFeatures(enable_features)
  318. disable_features_splits = _SplitFeatures(disable_features)
  319. field_trials_splits = _SplitFieldTrials(field_trials, field_trial_params)
  320. splits = []
  321. for index in range(2):
  322. cmd_line = {}
  323. cmd_line.update(field_trials_splits[index])
  324. cmd_line[_ENABLE_FEATURES_SWITCH_NAME] = enable_features_splits[index]
  325. cmd_line[_DISABLE_FEATURES_SWITCH_NAME] = disable_features_splits[index]
  326. splits.append(cmd_line)
  327. return splits
  328. def SplitVariationsCmdFromString(input_string):
  329. """Splits commandline switches.
  330. This function can be called recursively when bisecting a set of experiments
  331. until one is identified to be responsble for a certain browser behavior.
  332. Same as SplitVariationsCmd(), except data comes from a string rather than
  333. an internal representation.
  334. Args:
  335. input_string: Variations string to be split.
  336. Returns:
  337. If input can be split, returns a list of two strings, each is half of
  338. the input variations cmd; otherwise, returns a list of one string.
  339. """
  340. data = ParseVariationsCmdFromString(input_string)
  341. splits = SplitVariationsCmd(data)
  342. results = []
  343. for split in splits:
  344. cmd_list = VariationsCmdToStrings(split)
  345. if cmd_list:
  346. results.append(' '.join(cmd_list))
  347. return results
  348. def SplitVariationsCmdFromFile(input_filename, output_dir=None):
  349. """Splits commandline switches.
  350. This function can be called recursively when bisecting a set of experiments
  351. until one is identified to be responsble for a certain browser behavior.
  352. Same as SplitVariationsCmd(), except data comes from a file rather than
  353. an internal representation.
  354. Args:
  355. input_filename: Variations file to be split.
  356. output_dir: Folder to output the split variations file(s). If None,
  357. output to the same folder as the input_filename. If the folder
  358. doesn't exist, it will be created.
  359. Returns:
  360. If input can be split, returns a list of two output filenames;
  361. otherwise, returns a list of one output filename.
  362. """
  363. with open(input_filename, 'r') as f:
  364. input_string = f.read().replace('\n', ' ')
  365. splits = SplitVariationsCmdFromString(input_string)
  366. dirname, filename = os.path.split(input_filename)
  367. basename, ext = os.path.splitext(filename)
  368. if output_dir is None:
  369. output_dir = dirname
  370. if not os.path.exists(output_dir):
  371. os.makedirs(output_dir)
  372. split_filenames = []
  373. for index in range(len(splits)):
  374. output_filename = "%s_%d%s" % (basename, index + 1, ext)
  375. output_filename = os.path.join(output_dir, output_filename)
  376. with open(output_filename, 'w') as output_file:
  377. output_file.write(splits[index])
  378. split_filenames.append(output_filename)
  379. return split_filenames
  380. def main():
  381. parser = optparse.OptionParser()
  382. parser.add_option("-f", "--file", dest="filename", metavar="FILE",
  383. help="specify a file with variations cmd for processing.")
  384. parser.add_option("--output-dir", dest="output_dir",
  385. help="specify a folder where output files are saved. "
  386. "If not specified, it is the folder of the input file.")
  387. options, _ = parser.parse_args()
  388. if not options.filename:
  389. parser.error("Input file is not specificed")
  390. SplitVariationsCmdFromFile(options.filename, options.output_dir)
  391. return 0
  392. if __name__ == '__main__':
  393. sys.exit(main())