binary_search_state.py revision b51148ca9f8b8a9a1322342836ac5906f2564f85
1#!/usr/bin/python2
2"""The binary search wrapper."""
3
4from __future__ import print_function
5
6import argparse
7import math
8import os
9import pickle
10import sys
11import tempfile
12
13# Programtically adding utils python path to PYTHONPATH
14if os.path.isabs(sys.argv[0]):
15  utils_pythonpath = os.path.abspath('{0}/..'.format(os.path.dirname(sys.argv[
16      0])))
17else:
18  wdir = os.getcwd()
19  utils_pythonpath = os.path.abspath('{0}/{1}/..'.format(wdir, os.path.dirname(
20      sys.argv[0])))
21sys.path.append(utils_pythonpath)
22# Now we do import from utils
23from utils import command_executer
24from utils import logger
25
26import binary_search_perforce
27
28STATE_FILE = '%s.state' % sys.argv[0]
29HIDDEN_STATE_FILE = os.path.join(
30    os.path.dirname(STATE_FILE), '.%s' % os.path.basename(STATE_FILE))
31
32class Error(Exception):
33  """The general binary search tool error class."""
34  pass
35
36
37class BinarySearchState(object):
38  """The binary search state class."""
39
40  def __init__(self, get_initial_items, switch_to_good, switch_to_bad,
41               install_script, test_script, incremental, prune, iterations,
42               prune_iterations, verify_level, file_args, verbose):
43    self.get_initial_items = get_initial_items
44    self.switch_to_good = switch_to_good
45    self.switch_to_bad = switch_to_bad
46    self.install_script = install_script
47    self.test_script = test_script
48    self.incremental = incremental
49    self.prune = prune
50    self.iterations = iterations
51    self.prune_iterations = prune_iterations
52    self.verify_level = verify_level
53    self.file_args = file_args
54    self.verbose = verbose
55
56    self.l = logger.GetLogger()
57    self.ce = command_executer.GetCommandExecuter()
58
59    self.resumed = False
60    self.prune_cycles = 0
61    self.search_cycles = 0
62    self.num_bad_items_history = []
63    self.bs = None
64    self.all_items = None
65    self.PopulateItemsUsingCommand(self.get_initial_items)
66    self.currently_good_items = set([])
67    self.currently_bad_items = set([])
68    self.found_items = set([])
69
70  def SwitchToGood(self, item_list):
71    if self.incremental:
72      self.l.LogOutput('Incremental set. Wanted to switch %s to good' %
73                       str(item_list), print_to_console=self.verbose)
74      incremental_items = [
75          item for item in item_list if item not in self.currently_good_items
76      ]
77      item_list = incremental_items
78      self.l.LogOutput('Incremental set. Actually switching %s to good' %
79                       str(item_list), print_to_console=self.verbose)
80
81    if not item_list:
82      return
83
84    self.l.LogOutput('Switching %s to good' % str(item_list),
85                     print_to_console=self.verbose)
86    self.RunSwitchScript(self.switch_to_good, item_list)
87    self.currently_good_items = self.currently_good_items.union(set(item_list))
88    self.currently_bad_items.difference_update(set(item_list))
89
90  def SwitchToBad(self, item_list):
91    if self.incremental:
92      self.l.LogOutput('Incremental set. Wanted to switch %s to bad' %
93                       str(item_list), print_to_console=self.verbose)
94      incremental_items = [
95          item for item in item_list if item not in self.currently_bad_items
96      ]
97      item_list = incremental_items
98      self.l.LogOutput('Incremental set. Actually switching %s to bad' %
99                       str(item_list), print_to_console=self.verbose)
100
101    if not item_list:
102      return
103
104    self.l.LogOutput('Switching %s to bad' % str(item_list),
105                     print_to_console=self.verbose)
106    self.RunSwitchScript(self.switch_to_bad, item_list)
107    self.currently_bad_items = self.currently_bad_items.union(set(item_list))
108    self.currently_good_items.difference_update(set(item_list))
109
110  def RunSwitchScript(self, switch_script, item_list):
111    if self.file_args:
112      with tempfile.NamedTemporaryFile() as f:
113        f.write('\n'.join(item_list))
114        f.flush()
115        command = '%s %s' % (switch_script, f.name)
116        ret, _, _ = self.ce.RunCommandWExceptionCleanup(
117            command, print_to_console=self.verbose)
118    else:
119      command = '%s %s' % (switch_script, ' '.join(item_list))
120      ret, _, _ = self.ce.RunCommandWExceptionCleanup(
121          command, print_to_console=self.verbose)
122    assert ret == 0, 'Switch script %s returned %d' % (switch_script, ret)
123
124  def TestScript(self):
125    command = self.test_script
126    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
127    return ret
128
129  def InstallScript(self):
130    if not self.install_script:
131      return 0
132
133    command = self.install_script
134    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
135    return ret
136
137  def DoVerify(self):
138    self.l.LogOutput('VERIFICATION')
139    self.l.LogOutput('Beginning %d tests to verify good/bad sets\n')
140    for _ in range(int(self.verify_level)):
141      self.l.LogOutput('Resetting all items to good to verify.')
142      self.SwitchToGood(self.all_items)
143      status = self.InstallScript()
144      assert status == 0, 'When reset_to_good, install should succeed.'
145      status = self.TestScript()
146      assert status == 0, 'When reset_to_good, status should be 0.'
147
148      self.l.LogOutput('Resetting all items to bad to verify.')
149      self.SwitchToBad(self.all_items)
150      status = self.InstallScript()
151      assert status == 0, 'When reset_to_bad, install should succeed.'
152      status = self.TestScript()
153      assert status == 1, 'When reset_to_bad, status should be 1.'
154
155  def DoSearch(self):
156    while (True and
157           len(self.all_items) > 1 and
158           self.prune_cycles < self.prune_iterations):
159      terminated = self.DoBinarySearch()
160      self.prune_cycles += 1
161      if not terminated:
162        break
163      if not self.prune:
164        self.l.LogOutput('Not continuning further, --prune is not set')
165        break
166      # Prune is set.
167      prune_index = self.bs.current
168
169      if prune_index == len(self.all_items) - 1:
170        self.l.LogOutput('First bad item is the last item. Breaking.')
171        self.l.LogOutput('Bad items are: %s' % self.all_items[-1])
172        break
173
174      num_bad_items = len(self.all_items) - prune_index
175      self.num_bad_items_history.append(num_bad_items)
176
177      if (self.num_bad_items_history[-num_bad_items:] ==
178          [num_bad_items for _ in range(num_bad_items)]):
179        self.l.LogOutput('num_bad_items_history: %s for past %d iterations. '
180                         'Breaking.' % (str(self.num_bad_items_history),
181                                        num_bad_items))
182        self.l.LogOutput('Bad items are: %s' %
183                         ' '.join(self.all_items[prune_index:]))
184        break
185
186      new_all_items = list(self.all_items)
187      # Move prune item to the end of the list.
188      new_all_items.append(new_all_items.pop(prune_index))
189      self.found_items.add(new_all_items[-1])
190
191      if prune_index:
192        new_all_items = new_all_items[prune_index - 1:]
193
194      self.l.LogOutput('Old list: %s. New list: %s' % (str(self.all_items),
195                                                       str(new_all_items)),
196                       print_to_console=self.verbose)
197
198      # FIXME: Do we need to Convert the currently good items to bad
199      self.PopulateItemsUsingList(new_all_items)
200
201  def DoBinarySearch(self):
202    # If in resume mode don't reset search_cycles
203    if not self.resumed:
204      self.search_cycles = 0
205    else:
206      self.resumed = False
207
208    terminated = False
209    while self.search_cycles < self.iterations and not terminated:
210      self.SaveState()
211      self.OutputProgress()
212      self.search_cycles += 1
213      [bad_items, good_items] = self.GetNextItems()
214
215      # TODO: bad_items should come first.
216      self.SwitchToGood(good_items)
217      self.SwitchToBad(bad_items)
218      status = self.InstallScript()
219      if status == 0:
220        status = self.TestScript()
221      else:
222        # Install script failed, treat as skipped item
223        status = 2
224      terminated = self.bs.SetStatus(status)
225
226      if terminated:
227        self.l.LogOutput('Terminated!')
228    if not terminated:
229      self.l.LogOutput('Ran out of iterations searching...')
230    self.l.LogOutput(str(self), print_to_console=self.verbose)
231    return terminated
232
233  def PopulateItemsUsingCommand(self, command):
234    ce = command_executer.GetCommandExecuter()
235    _, out, _ = ce.RunCommandWExceptionCleanup(command,
236                                               return_output=True,
237                                               print_to_console=self.verbose)
238    all_items = out.split()
239    self.PopulateItemsUsingList(all_items)
240
241  def PopulateItemsUsingList(self, all_items):
242    self.all_items = all_items
243    self.bs = binary_search_perforce.BinarySearcher(logger_to_set=self.l)
244    self.bs.SetSortedList(self.all_items)
245
246  def SaveState(self):
247    ce, l = self.ce, self.l
248    self.ce, self.l, self.bs.logger = None, None, None
249    old_state = None
250
251    _, path = tempfile.mkstemp(prefix=HIDDEN_STATE_FILE, dir='.')
252    with open(path, 'wb') as f:
253      pickle.dump(self, f)
254
255    if os.path.exists(STATE_FILE):
256      if os.path.islink(STATE_FILE):
257        old_state = os.readlink(STATE_FILE)
258      else:
259        raise Error(('%s already exists and is not a symlink!\n'
260                     'State file saved to %s' % (STATE_FILE, path)))
261
262    # Create new link and atomically overwrite old link
263    temp_link = '%s.link' % HIDDEN_STATE_FILE
264    os.symlink(path, temp_link)
265    os.rename(temp_link, STATE_FILE)
266
267    if old_state:
268      os.remove(old_state)
269
270    self.ce, self.l, self.bs.logger = ce, l, l
271
272  @classmethod
273  def LoadState(cls):
274    if not os.path.isfile(STATE_FILE):
275      return None
276    try:
277      bss = pickle.load(file(STATE_FILE))
278      bss.l = logger.GetLogger()
279      bss.ce = command_executer.GetCommandExecuter()
280      bss.bs.logger = bss.l
281      bss.resumed = True
282      binary_search_perforce.verbose = bss.verbose
283      return bss
284    except Exception:
285      return None
286
287  def RemoveState(self):
288    if os.path.exists(STATE_FILE):
289      if os.path.islink(STATE_FILE):
290        real_file = os.readlink(STATE_FILE)
291        os.remove(real_file)
292        os.remove(STATE_FILE)
293
294  def GetNextItems(self):
295    border_item = self.bs.GetNext()
296    index = self.all_items.index(border_item)
297
298    next_bad_items = self.all_items[:index + 1]
299    next_good_items = self.all_items[index + 1:]
300
301    return [next_bad_items, next_good_items]
302
303  def OutputProgress(self):
304    out = ('\n********** PROGRESS **********\n'
305           'Search %d of estimated %d.\n'
306           'Prune %d of max %d.\n'
307           'Current bad items found:\n'
308           '%s\n'
309           '******************************')
310    out = out % (self.search_cycles + 1,
311                 math.ceil(math.log(len(self.all_items), 2)),
312                 self.prune_cycles + 1,
313                 self.prune_iterations,
314                 str(self.found_items))
315
316    self.l.LogOutput(out)
317
318  def __str__(self):
319    ret = ''
320    ret += 'all: %s\n' % str(self.all_items)
321    ret += 'currently_good: %s\n' % str(self.currently_good_items)
322    ret += 'currently_bad: %s\n' % str(self.currently_bad_items)
323    ret += str(self.bs)
324    return ret
325
326
327class MockBinarySearchState(BinarySearchState):
328  """Mock class for BinarySearchState."""
329
330  def __init__(self, **kwargs):
331    # Initialize all arguments to None
332    default_kwargs = {
333        'get_initial_items': 'echo "1"',
334        'switch_to_good': None,
335        'switch_to_bad': None,
336        'install_script': None,
337        'test_script': None,
338        'incremental': None,
339        'prune': None,
340        'iterations': None,
341        'prune_iterations': None,
342        'verify_level': None,
343        'file_args': None,
344        'verbose': None
345    }
346    default_kwargs.update(kwargs)
347    super(MockBinarySearchState, self).__init__(**default_kwargs)
348
349
350def _CanonicalizeScript(script_name):
351  script_name = os.path.expanduser(script_name)
352  if not script_name.startswith('/'):
353    return os.path.join('.', script_name)
354
355
356def Main(argv):
357  """The main function."""
358  # Common initializations
359
360  parser = argparse.ArgumentParser()
361  parser.add_argument('-n',
362                      '--iterations',
363                      dest='iterations',
364                      help='Number of iterations to try in the search.',
365                      default=50)
366  parser.add_argument('-i',
367                      '--get_initial_items',
368                      dest='get_initial_items',
369                      help=('Script to run to get the initial objects. '
370                            'If your script requires user input '
371                            'the --verbose option must be used'))
372  parser.add_argument('-g',
373                      '--switch_to_good',
374                      dest='switch_to_good',
375                      help=('Script to run to switch to good. '
376                            'If your switch script requires user input '
377                            'the --verbose option must be used'))
378  parser.add_argument('-b',
379                      '--switch_to_bad',
380                      dest='switch_to_bad',
381                      help=('Script to run to switch to bad. '
382                            'If your switch script requires user input '
383                            'the --verbose option must be used'))
384  parser.add_argument('-I',
385                      '--install_script',
386                      dest='install_script',
387                      default=None,
388                      help=('Optional script to perform building, flashing, '
389                            'and other setup before the test script runs.'))
390  parser.add_argument('-t',
391                      '--test_script',
392                      dest='test_script',
393                      help=('Script to run to test the '
394                            'output after packages are built.'))
395  parser.add_argument('-p',
396                      '--prune',
397                      dest='prune',
398                      action='store_true',
399                      default=False,
400                      help=('Script to run to test the output after '
401                            'packages are built.'))
402  parser.add_argument('-c',
403                      '--noincremental',
404                      dest='noincremental',
405                      action='store_true',
406                      default=False,
407                      help='Do not propagate good/bad changes incrementally.')
408  parser.add_argument('-f',
409                      '--file_args',
410                      dest='file_args',
411                      action='store_true',
412                      default=False,
413                      help='Use a file to pass arguments to scripts.')
414  parser.add_argument('-v',
415                      '--verify_level',
416                      dest='verify_level',
417                      default=1,
418                      help=('Check binary search assumptions N times '
419                            'before starting.'))
420  parser.add_argument('-N',
421                      '--prune_iterations',
422                      dest='prune_iterations',
423                      help='Number of prune iterations to try in the search.',
424                      default=100)
425  parser.add_argument('-V',
426                      '--verbose',
427                      dest='verbose',
428                      action='store_true',
429                      help='Print full output to console.')
430  parser.add_argument('-r',
431                      '--resume',
432                      dest='resume',
433                      action='store_true',
434                      help=('Resume bisection tool execution from state file.'
435                            'Useful if the last bisection was terminated '
436                            'before it could properly finish.'))
437
438  logger.GetLogger().LogOutput(' '.join(argv))
439  options = parser.parse_args(argv)
440
441  if not (options.get_initial_items and options.switch_to_good and
442          options.switch_to_bad and options.test_script) and not options.resume:
443    parser.print_help()
444    return 1
445
446  if options.resume:
447    logger.GetLogger().LogOutput('Resuming from %s' % STATE_FILE)
448    if len(argv) > 1:
449      logger.GetLogger().LogOutput(('Note: resuming from previous state, '
450                                    'ignoring given options and loading saved '
451                                    'options instead.'))
452    bss = BinarySearchState.LoadState()
453    if not bss:
454      logger.GetLogger().LogOutput(
455          '%s is not a valid binary_search_tool state file, cannot resume!' %
456          STATE_FILE)
457      return 1
458  else:
459    iterations = int(options.iterations)
460    switch_to_good = _CanonicalizeScript(options.switch_to_good)
461    switch_to_bad = _CanonicalizeScript(options.switch_to_bad)
462    install_script = options.install_script
463    if install_script:
464      install_script = _CanonicalizeScript(options.install_script)
465    test_script = _CanonicalizeScript(options.test_script)
466    get_initial_items = _CanonicalizeScript(options.get_initial_items)
467    prune = options.prune
468    prune_iterations = options.prune_iterations
469    verify_level = options.verify_level
470    file_args = options.file_args
471    verbose = options.verbose
472    binary_search_perforce.verbose = verbose
473
474    if options.noincremental:
475      incremental = False
476    else:
477      incremental = True
478
479    bss = BinarySearchState(get_initial_items, switch_to_good, switch_to_bad,
480                            install_script, test_script, incremental, prune,
481                            iterations, prune_iterations, verify_level,
482                            file_args, verbose)
483    bss.DoVerify()
484
485  try:
486    bss.DoSearch()
487    bss.RemoveState()
488  except Error as e:
489    logger.GetLogger().LogError(e)
490    return 1
491
492  return 0
493
494
495if __name__ == '__main__':
496  sys.exit(Main(sys.argv[1:]))
497