binary_search_state.py revision 19b6f5fc11dcf97144e9723c8f78534cce27423a
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
12import time
13
14# Adds utils to PYTHONPATH
15import common
16
17# Now we do import from utils
18from utils import command_executer
19from utils import logger
20
21import binary_search_perforce
22
23STATE_FILE = '%s.state' % sys.argv[0]
24HIDDEN_STATE_FILE = os.path.join(
25    os.path.dirname(STATE_FILE), '.%s' % os.path.basename(STATE_FILE))
26
27class Error(Exception):
28  """The general binary search tool error class."""
29  pass
30
31
32class BinarySearchState(object):
33  """The binary search state class."""
34
35  def __init__(self, get_initial_items, switch_to_good, switch_to_bad,
36               install_script, test_script, incremental, prune, iterations,
37               prune_iterations, verify_level, file_args, verbose):
38    """BinarySearchState constructor, see Run for full args documentation."""
39    self.get_initial_items = get_initial_items
40    self.switch_to_good = switch_to_good
41    self.switch_to_bad = switch_to_bad
42    self.install_script = install_script
43    self.test_script = test_script
44    self.incremental = incremental
45    self.prune = prune
46    self.iterations = iterations
47    self.prune_iterations = prune_iterations
48    self.verify_level = verify_level
49    self.file_args = file_args
50    self.verbose = verbose
51
52    self.l = logger.GetLogger()
53    self.ce = command_executer.GetCommandExecuter()
54
55    self.resumed = False
56    self.prune_cycles = 0
57    self.search_cycles = 0
58    self.binary_search = None
59    self.all_items = None
60    self.PopulateItemsUsingCommand(self.get_initial_items)
61    self.currently_good_items = set([])
62    self.currently_bad_items = set([])
63    self.found_items = set([])
64
65    self.start_time = time.time()
66
67  def SwitchToGood(self, item_list):
68    """Switch given items to "good" set."""
69    if self.incremental:
70      self.l.LogOutput('Incremental set. Wanted to switch %s to good' %
71                       str(item_list), print_to_console=self.verbose)
72      incremental_items = [
73          item for item in item_list if item not in self.currently_good_items
74      ]
75      item_list = incremental_items
76      self.l.LogOutput('Incremental set. Actually switching %s to good' %
77                       str(item_list), print_to_console=self.verbose)
78
79    if not item_list:
80      return
81
82    self.l.LogOutput('Switching %s to good' % str(item_list),
83                     print_to_console=self.verbose)
84    self.RunSwitchScript(self.switch_to_good, item_list)
85    self.currently_good_items = self.currently_good_items.union(set(item_list))
86    self.currently_bad_items.difference_update(set(item_list))
87
88  def SwitchToBad(self, item_list):
89    """Switch given items to "bad" set."""
90    if self.incremental:
91      self.l.LogOutput('Incremental set. Wanted to switch %s to bad' %
92                       str(item_list), print_to_console=self.verbose)
93      incremental_items = [
94          item for item in item_list if item not in self.currently_bad_items
95      ]
96      item_list = incremental_items
97      self.l.LogOutput('Incremental set. Actually switching %s to bad' %
98                       str(item_list), print_to_console=self.verbose)
99
100    if not item_list:
101      return
102
103    self.l.LogOutput('Switching %s to bad' % str(item_list),
104                     print_to_console=self.verbose)
105    self.RunSwitchScript(self.switch_to_bad, item_list)
106    self.currently_bad_items = self.currently_bad_items.union(set(item_list))
107    self.currently_good_items.difference_update(set(item_list))
108
109  def RunSwitchScript(self, switch_script, item_list):
110    """Pass given items to switch script.
111
112    Args:
113      switch_script: path to switch script
114      item_list: list of all items to be switched
115    """
116    if self.file_args:
117      with tempfile.NamedTemporaryFile() as f:
118        f.write('\n'.join(item_list))
119        f.flush()
120        command = '%s %s' % (switch_script, f.name)
121        ret, _, _ = self.ce.RunCommandWExceptionCleanup(
122            command, print_to_console=self.verbose)
123    else:
124      command = '%s %s' % (switch_script, ' '.join(item_list))
125      ret, _, _ = self.ce.RunCommandWExceptionCleanup(
126          command, print_to_console=self.verbose)
127    assert ret == 0, 'Switch script %s returned %d' % (switch_script, ret)
128
129  def TestScript(self):
130    """Run test script and return exit code from script."""
131    command = self.test_script
132    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
133    return ret
134
135  def InstallScript(self):
136    """Run install script and return exit code from script."""
137    if not self.install_script:
138      return 0
139
140    command = self.install_script
141    ret, _, _ = self.ce.RunCommandWExceptionCleanup(command)
142    return ret
143
144  def DoVerify(self):
145    """Verify correctness of test environment.
146
147    Verify that a "good" set of items produces a "good" result and that a "bad"
148    set of items produces a "bad" result. To be run directly before running
149    DoSearch. If verify_level is 0 this step is skipped.
150    """
151    if not self.verify_level:
152      return
153
154    self.l.LogOutput('VERIFICATION')
155    self.l.LogOutput('Beginning %d tests to verify good/bad sets\n' %
156                     self.verify_level)
157    for _ in range(int(self.verify_level)):
158      self.l.LogOutput('Resetting all items to good to verify.')
159      self.SwitchToGood(self.all_items)
160      status = self.InstallScript()
161      assert status == 0, 'When reset_to_good, install should succeed.'
162      status = self.TestScript()
163      assert status == 0, 'When reset_to_good, status should be 0.'
164
165      self.l.LogOutput('Resetting all items to bad to verify.')
166      self.SwitchToBad(self.all_items)
167      status = self.InstallScript()
168      assert status == 0, 'When reset_to_bad, install should succeed.'
169      status = self.TestScript()
170      assert status == 1, 'When reset_to_bad, status should be 1.'
171
172  def DoSearch(self):
173    """Perform full search for bad items.
174
175    Perform full search until prune_iterations number of bad items are found.
176    """
177    while (True and
178           len(self.all_items) > 1 and
179           self.prune_cycles < self.prune_iterations):
180      terminated = self.DoBinarySearch()
181      self.prune_cycles += 1
182      if not terminated:
183        break
184      # Prune is set.
185      prune_index = self.binary_search.current
186
187      # If already seen item we have no new bad items to find, finish up
188      if self.all_items[prune_index] in self.found_items:
189        self.l.LogOutput(('Found item already found before: %s. '
190                          'Done searching.' % self.all_items[prune_index]))
191        self.l.LogOutput('Bad items are: %s' %
192                         ' '.join(self.found_items))
193        break
194
195      new_all_items = list(self.all_items)
196      # Move prune item to the end of the list.
197      new_all_items.append(new_all_items.pop(prune_index))
198      self.found_items.add(new_all_items[-1])
199
200      if prune_index:
201        new_all_items = new_all_items[prune_index - 1:]
202
203      self.l.LogOutput('Old list: %s. New list: %s' % (str(self.all_items),
204                                                       str(new_all_items)),
205                       print_to_console=self.verbose)
206
207      if not self.prune:
208        self.l.LogOutput('Not continuning further, --prune is not set')
209        break
210      # FIXME: Do we need to Convert the currently good items to bad
211      self.PopulateItemsUsingList(new_all_items)
212
213  def DoBinarySearch(self):
214    """Perform single iteration of binary search."""
215    # If in resume mode don't reset search_cycles
216    if not self.resumed:
217      self.search_cycles = 0
218    else:
219      self.resumed = False
220
221    terminated = False
222    while self.search_cycles < self.iterations and not terminated:
223      self.SaveState()
224      self.OutputProgress()
225
226      self.search_cycles += 1
227      [bad_items, good_items] = self.GetNextItems()
228
229      # TODO: bad_items should come first.
230      self.SwitchToGood(good_items)
231      self.SwitchToBad(bad_items)
232      status = self.InstallScript()
233      if status == 0:
234        status = self.TestScript()
235      else:
236        # Install script failed, treat as skipped item
237        status = 2
238      terminated = self.binary_search.SetStatus(status)
239
240      if terminated:
241        self.l.LogOutput('Terminated!')
242    if not terminated:
243      self.l.LogOutput('Ran out of iterations searching...')
244    self.l.LogOutput(str(self), print_to_console=self.verbose)
245    return terminated
246
247  def PopulateItemsUsingCommand(self, command):
248    """Update all_items and binary search logic from executable.
249
250    This method is mainly required for enumerating the initial list of items
251    from the get_initial_items script.
252
253    Args:
254      command: path to executable that will enumerate items.
255    """
256    ce = command_executer.GetCommandExecuter()
257    _, out, _ = ce.RunCommandWExceptionCleanup(command,
258                                               return_output=True,
259                                               print_to_console=self.verbose)
260    all_items = out.split()
261    self.PopulateItemsUsingList(all_items)
262
263  def PopulateItemsUsingList(self, all_items):
264    """Update all_items and binary searching logic from list.
265
266    Args:
267      all_items: new list of all_items
268    """
269    self.all_items = all_items
270    self.binary_search = binary_search_perforce.BinarySearcher(
271        logger_to_set=self.l)
272    self.binary_search.SetSortedList(self.all_items)
273
274  def SaveState(self):
275    """Save state to STATE_FILE.
276
277    SaveState will create a new unique, hidden state file to hold data from
278    object. Then atomically overwrite the STATE_FILE symlink to point to the
279    new data.
280
281    Raises:
282      Error if STATE_FILE already exists but is not a symlink.
283    """
284    ce, l = self.ce, self.l
285    self.ce, self.l, self.binary_search.logger = None, None, None
286    old_state = None
287
288    _, path = tempfile.mkstemp(prefix=HIDDEN_STATE_FILE, dir='.')
289    with open(path, 'wb') as f:
290      pickle.dump(self, f)
291
292    if os.path.exists(STATE_FILE):
293      if os.path.islink(STATE_FILE):
294        old_state = os.readlink(STATE_FILE)
295      else:
296        raise Error(('%s already exists and is not a symlink!\n'
297                     'State file saved to %s' % (STATE_FILE, path)))
298
299    # Create new link and atomically overwrite old link
300    temp_link = '%s.link' % HIDDEN_STATE_FILE
301    os.symlink(path, temp_link)
302    os.rename(temp_link, STATE_FILE)
303
304    if old_state:
305      os.remove(old_state)
306
307    self.ce, self.l, self.binary_search.logger = ce, l, l
308
309  @classmethod
310  def LoadState(cls):
311    """Create BinarySearchState object from STATE_FILE."""
312    if not os.path.isfile(STATE_FILE):
313      return None
314    try:
315      bss = pickle.load(file(STATE_FILE))
316      bss.l = logger.GetLogger()
317      bss.ce = command_executer.GetCommandExecuter()
318      bss.binary_search.logger = bss.l
319      bss.start_time = time.time()
320      bss.resumed = True
321      binary_search_perforce.verbose = bss.verbose
322      return bss
323    except Exception:
324      return None
325
326  def RemoveState(self):
327    """Remove STATE_FILE and its symlinked data from file system."""
328    if os.path.exists(STATE_FILE):
329      if os.path.islink(STATE_FILE):
330        real_file = os.readlink(STATE_FILE)
331        os.remove(real_file)
332        os.remove(STATE_FILE)
333
334  def GetNextItems(self):
335    """Get next items for binary search based on result of the last test run."""
336    border_item = self.binary_search.GetNext()
337    index = self.all_items.index(border_item)
338
339    next_bad_items = self.all_items[:index + 1]
340    next_good_items = self.all_items[index + 1:]
341
342    return [next_bad_items, next_good_items]
343
344  def ElapsedTimeString(self):
345    """Return h m s format of elapsed time since execution has started."""
346    diff = int(time.time() - self.start_time)
347    seconds = diff % 60
348    minutes = (diff / 60) % 60
349    hours = diff / (60 * 60)
350
351    seconds = str(seconds).rjust(2)
352    minutes = str(minutes).rjust(2)
353    hours = str(hours).rjust(2)
354
355    return '%sh %sm %ss' % (hours, minutes, seconds)
356
357  def OutputProgress(self):
358    """Output current progress of binary search to console and logs."""
359    out = ('\n***** PROGRESS (elapsed time: %s) *****\n'
360           'Search %d of estimated %d.\n'
361           'Prune %d of max %d.\n'
362           'Current bad items found:\n'
363           '%s\n'
364           '************************************************')
365    out = out % (self.ElapsedTimeString(),
366                 self.search_cycles + 1,
367                 math.ceil(math.log(len(self.all_items), 2)),
368                 self.prune_cycles + 1,
369                 self.prune_iterations,
370                 str(self.found_items))
371
372    self.l.LogOutput(out)
373
374  def __str__(self):
375    ret = ''
376    ret += 'all: %s\n' % str(self.all_items)
377    ret += 'currently_good: %s\n' % str(self.currently_good_items)
378    ret += 'currently_bad: %s\n' % str(self.currently_bad_items)
379    ret += str(self.binary_search)
380    return ret
381
382
383class MockBinarySearchState(BinarySearchState):
384  """Mock class for BinarySearchState."""
385
386  def __init__(self, **kwargs):
387    # Initialize all arguments to None
388    default_kwargs = {
389        'get_initial_items': 'echo "1"',
390        'switch_to_good': None,
391        'switch_to_bad': None,
392        'install_script': None,
393        'test_script': None,
394        'incremental': True,
395        'prune': False,
396        'iterations': 50,
397        'prune_iterations': 100,
398        'verify_level': 1,
399        'file_args': False,
400        'verbose': False
401    }
402    default_kwargs.update(kwargs)
403    super(MockBinarySearchState, self).__init__(**default_kwargs)
404
405
406def _CanonicalizeScript(script_name):
407  """Return canonical path to script.
408
409  Args:
410    script_name: Relative or absolute path to script
411
412  Returns:
413    Canonicalized script path
414  """
415  script_name = os.path.expanduser(script_name)
416  if not script_name.startswith('/'):
417    return os.path.join('.', script_name)
418
419
420def Run(get_initial_items, switch_to_good, switch_to_bad, test_script,
421        install_script=None, iterations=50, prune=True, noincremental=False,
422        file_args=False, verify_level=1, prune_iterations=100, verbose=False,
423        resume=False):
424  """Run binary search tool. Equivalent to running through terminal.
425
426  Args:
427    get_initial_items: Script to enumerate all items being binary searched
428    switch_to_good: Script that will take items as input and switch them to good
429                    set
430    switch_to_bad: Script that will take items as input and switch them to bad
431                   set
432    test_script: Script that will determine if the current combination of good
433                 and bad items make a "good" or "bad" result.
434    install_script: Script to do necessary setup (building, compilation, etc.)
435                    for test_script
436    iterations: How many binary search iterations to run before exiting.
437    prune: If False the binary search tool will stop when the first bad item is
438           found. Otherwise then binary search tool will continue searching
439           until all bad items are found (or prune_iterations is reached).
440    noincremental: Whether to send "diffs" of good/bad items to switch scripts.
441    file_args: If True then arguments to switch scripts will be a file name
442               containing a newline separated list of the items to switch.
443    verify_level: How many verification tests to run to ensure initial good/bad
444                  sets actually produce a good/bad result.
445    prune_iterations: Max number of bad items to search for.
446    verbose: If True will print extra debug information to user.
447    resume: If True will resume using STATE_FILE.
448
449  Returns:
450    0 for success, error otherwise
451  """
452  if resume:
453    bss = BinarySearchState.LoadState()
454    if not bss:
455      logger.GetLogger().LogOutput(
456          '%s is not a valid binary_search_tool state file, cannot resume!' %
457          STATE_FILE)
458      return 1
459  else:
460    switch_to_good = _CanonicalizeScript(switch_to_good)
461    switch_to_bad = _CanonicalizeScript(switch_to_bad)
462    if install_script:
463      install_script = _CanonicalizeScript(install_script)
464    test_script = _CanonicalizeScript(test_script)
465    get_initial_items = _CanonicalizeScript(get_initial_items)
466    incremental = not noincremental
467
468    binary_search_perforce.verbose = verbose
469
470    bss = BinarySearchState(get_initial_items, switch_to_good, switch_to_bad,
471                            install_script, test_script, incremental, prune,
472                            iterations, prune_iterations, verify_level,
473                            file_args, verbose)
474    bss.DoVerify()
475
476  try:
477    bss.DoSearch()
478    bss.RemoveState()
479    logger.GetLogger().LogOutput('Total execution time: %s' %
480                                 bss.ElapsedTimeString())
481  except Error as e:
482    logger.GetLogger().LogError(e)
483    return 1
484
485  return 0
486
487
488def Main(argv):
489  """The main function."""
490  # Common initializations
491
492  parser = argparse.ArgumentParser()
493  common.BuildArgParser(parser)
494  logger.GetLogger().LogOutput(' '.join(argv))
495  options = parser.parse_args(argv)
496
497  if not (options.get_initial_items and options.switch_to_good and
498          options.switch_to_bad and options.test_script) and not options.resume:
499    parser.print_help()
500    return 1
501
502  if options.resume:
503    logger.GetLogger().LogOutput('Resuming from %s' % STATE_FILE)
504    if len(argv) > 1:
505      logger.GetLogger().LogOutput(('Note: resuming from previous state, '
506                                    'ignoring given options and loading saved '
507                                    'options instead.'))
508
509  # Get dictionary of all options
510  args = vars(options)
511  return Run(**args)
512
513
514if __name__ == '__main__':
515  sys.exit(Main(sys.argv[1:]))
516