<gh_stars>1-10
#!/bin/env python
# Copyright (c) 2006-2008 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.

# purify_analyze.py

''' Given a Purify text file, parses messages, normalizes and uniques them.
If there's an existing baseline of this data, it can compare against that
baseline and return an error code if there are any new errors not in the
baseline. '''

import logging
import optparse
import os
import re
import sys

import google.logging_utils
import google.path_utils

import purify_message

class MemoryTreeNode(object):
  ''' A node in a tree representing stack traces of memory allocation.
  Essentially, each node in the tree is a hashtable mapping a child
  function name to a child node.  Each node contains the total number
  of bytes of all of its descendants.
  See also: PurifyAnalyze.PrintMemoryInUse()
  '''

  pat_initializer = re.compile('(.*)\`dynamic initializer for \'(.*)\'\'')

  @classmethod
  def CreateTree(cls, message_list):
    '''Creates a tree from message_list. All of the Message objects are built
       into a tree with a default "ROOT" root node that is then returned.
    Args:
      message_list: a MessageList object.
    '''
    root = MemoryTreeNode("ROOT", 0, 0)
    msgs = message_list.AllMessages()
    for msg in msgs:
      bytes = msg._bytes
      blocks = msg._blocks
      stack = msg.GetAllocStack()
      stack_lines = stack.GetLines()
      size = len(stack_lines)
      node = root
      node._AddAlloc(bytes, blocks)
      counted = False
      # process stack lines from the bottom up to build a call-stack tree
      functions = [line["function"] for line in stack_lines]
      functions.reverse()
      for func in functions:
        if node == root:
          m = MemoryTreeNode.pat_initializer.match(func)
          if m:
            node = node._AddChild("INITIALIZERS", bytes, blocks)
            func = m.group(1) + m.group(2)
        # don't process ellided or truncated stack lines
        if func:
          node = node._AddChild(func, bytes, blocks)
          counted = True
      if not counted:
        # Nodes with no stack frames in our code wind up not being counted
        # above.  These seem to be attributable to Windows DLL
        # initialization, so just throw them into that bucket.
        node._AddChild("WINDOWS", bytes, blocks)
    return root

  def __init__(self, function, bytes, blocks):
    '''
    Args:
      function: A string representing a unique method or function.
      bytes: initial number of bytes allocated in this node
      blocks: initial number of blocks allocated in this node
    '''
    self._function = function
    self._bytes = bytes
    self._blocks = blocks
    self._allocs = 1
    self._children = {}

  def _AddAlloc(self, bytes, blocks):
    '''Adds bytes and blocks to this node's allocation totals
    '''
    self._allocs += 1
    self._bytes += bytes
    self._blocks += blocks

  def _AddChild(self, function, bytes, blocks):
    '''Adds a child node if not present.  Otherwise, adds
    bytes and blocks to it's allocation total.
    '''
    if function not in self._children:
      self._children[function] = MemoryTreeNode(function, bytes, blocks)
    else:
      self._children[function]._AddAlloc(bytes, blocks)
    return self._children[function]

  def __cmp__(self, other):
    # sort by size, then blocks, then function name
    return cmp((self._bytes, self._blocks, self._function),
               (other._bytes, other._blocks, other._function))

  def __str__(self):
    return "(%d bytes, %d blocks, %d allocs) %s" % (
        self._bytes, self._blocks, self._allocs, self._function)

  def PrintRecursive(self, padding="", byte_filter=0):
    '''Print the tree and all of its children recursively (depth-first).  All
    nodes at a given level of the tree are sorted in descending order by size.

    Args:
      padding: Printed at the front of the line.  Each recursive call adds a
        single space character.
      byte_filter: a number of bytes below which we'll prune the tree
    '''
    print "%s%s" % (padding, self)
    padding = padding + " "
    # sort the children in descending order (see __cmp__)
    swapped = self._children.values()
    swapped.sort(reverse=True)
    rest_bytes = 0
    rest_blocks = 0
    rest_allocs = 0
    for node in swapped:
      if node._bytes < byte_filter:
        rest_bytes += node._bytes
        rest_blocks += node._blocks
        rest_allocs += node._allocs
      else:
        node.PrintRecursive(padding, byte_filter)
    if rest_bytes:
      print "%s(%d bytes, %d blocks, %d allocs) PRUNED" % (padding,
        rest_bytes, rest_blocks, rest_allocs)

class PurifyAnalyze:
  ''' Given a Purify text file, parses all of the messages inside of it and
  normalizes them.  Provides a mechanism for comparing this normalized set
  against a baseline and detecting if new errors have been introduced. '''

  # a line which is the start of a new message
  pat_msg_start = re.compile('^\[([A-Z])\] (.*)$')
  # a message with a specific type
  pat_msg_type = re.compile('^([A-Z]{3}): (.*)$')
  pat_leak_summary = re.compile("Summary of ... memory leaks")
  pat_miu_summary = re.compile("Summary of ... memory in use")
  pat_starting = re.compile("Starting Purify'd ([^\\s]+\\\\[^\\s]+)")
  pat_arguments = re.compile("\s+Command line arguments:\s+([^\s].*)")
  pat_terminate = re.compile('Message: TerminateProcess called with code')
  # Purify treats this as a warning, but for us it's a fatal error.
  pat_instrumentation_failed = re.compile('^.* file not instrumented')
  # misc to ignore
  pat_ignore = (re.compile('^(Start|Exit)ing'),
                re.compile('^Program terminated'),
                re.compile('^Terminating thread'),
                re.compile('^Message: CryptVerifySignature'))
  # message types that aren't analyzed
  # handled, ignored and continued exceptions will likely never be interesting
  # TODO(erikkay): MPK ("potential" memory leaks) may be worth turning on
  types_excluded = ("EXH", "EXI", "EXC", "MPK")


  def __init__(self, files, echo, name=None, source_dir=None, data_dir=None,
               report_dir=None):
    # The input file we're analyzing.
    self._files = files

    # Whether the input file contents should be echoed to stdout.
    self._echo = echo

    # A symbolic name for the run being analyzed, often the name of the
    # exe which was purified.
    self._name = name

    # The top of the source code tree of the code we're analyzing.
    # This prefix is stripped from all filenames in stacks for normalization.
    if source_dir:
      purify_message.Stack.SetSourceDir(source_dir)

    script_dir = google.path_utils.ScriptDir()

    if data_dir:
      self._data_dir = data_dir
      self._global_data_dir = os.path.join(script_dir, "data")
    else:
      self._data_dir = os.path.join(script_dir, "data")
      self._global_data_dir = None

    if report_dir:
      self._report_dir = report_dir
    else:
      self._report_dir = os.path.join(script_dir, "latest")

    # A map of message_type to a MessageList of that type.
    self._message_lists = {}
    self._ReadIgnoreFile()

  def _ReadIgnoreFile(self):
    '''Read a file which is a list of regexps for either the title or the
    top-most visible stack line.
    '''
    self._pat_ignore = []
    filenames = [os.path.join(self._data_dir, "ignore.txt")]
    if self._global_data_dir:
      filenames.append(os.path.join(self._global_data_dir, "ignore.txt"))
    for filename in filenames:
      if os.path.exists(filename):
        f = open(filename, 'r')
        for line in f.readlines():
          if line.startswith("#") or line.startswith("//") or line.isspace():
            continue
          line = line.rstrip()
          pat = re.compile(line)
          if pat:
            self._pat_ignore.append(pat)

  def ShouldIgnore(self, msg):
    '''Should the message be ignored as irrelevant to analysis '''
    # never ignore memory in use
    if msg.Type() == "MIU":
      return False

    # check ignore patterns against title and top-most visible stack frames
    strings = [msg._title]
    err = msg.GetErrorStack()
    if err:
      line = err.GetTopVisibleStackLine().get('function', None)
      if line:
        strings.append(line)
    alloc = msg.GetAllocStack()
    if alloc:
      line = alloc.GetTopVisibleStackLine().get('function', None)
      if line:
        strings.append(line)
    for pat in self._pat_ignore:
      for str in strings:
        if pat.match(str):
          logging.debug("Igorning message based on ignore.txt")
          logging.debug(msg.NormalizedStr(verbose=True))
          return True

    # unless it's explicitly in the ignore file, never ignore these
    if msg.Type() == purify_message.FATAL:
      return False

    # certain message types aren't that interesting
    if msg.Type() in PurifyAnalyze.types_excluded:
      logging.debug("Igorning message because type is excluded")
      logging.debug(msg.NormalizedStr(verbose=True))
      return True
    # if the message stacks have no local stack frames, we can ignore them
    if msg.StacksAllExternal():
      logging.debug("Igorning message because stacks are all external")
      logging.debug(msg.NormalizedStr(verbose=True))
      return True

    # Microsoft's STL has a bunch of non-harmful UMRs in it.  Most of them
    # are filtered out by Purify's default filters and by our explicit ignore
    # list.  This code notices ones that have made it through so we can add
    # them to the ignore list later.
    if msg.Type() == "UMR":
      if err.GetTopStackLine()['file'].endswith('.'):
        logging.debug("non-ignored UMR in STL: %s" % msg._title)

    return False

  def AddMessage(self, msg):
    ''' Append the message to an array for its type.  Returns boolean indicating
    whether the message was actually added or was ignored.'''
    if msg:
      if self.ShouldIgnore(msg):
        return False
      if msg.Type() not in self._message_lists:
        self._message_lists[msg.Type()] = purify_message.MessageList(msg.Type())
      self._message_lists[msg.Type()].AddMessage(msg)
      return True
    return False

  def _BeginNewSublist(self, key):
    '''See MessageList.BeginNewSublist for details.
    '''
    if key in self._message_lists:
      self._message_lists[key].BeginNewSublist()

  def ReadFile(self):
    ''' Reads a Purify ASCII file and parses and normalizes the messages in
    the file.
    Returns False if a fatal error was detected, True otherwise.
    '''
    # Purify files consist of a series of "messages". These messages have a type
    # (designated as a three letter code - see message_type), a severity
    # (designated by a one letter code - see message_severity) and some
    # textual details.  It will often also have a stack trace of the error
    # location, and (for memory errors) may also have a stack trace of the
    # allocation location.

    fatal_errors = 0
    fatal_exe = ""

    for file in self._files:
      exe = ""
      error = None
      message = None
      for line in open(file, mode='rb'):
        line = line.rstrip()
        m = PurifyAnalyze.pat_msg_start.match(line)
        if m:
          if exe == fatal_exe:
            # since we hit a fatal error in this program, ignore all messages
            # until the program changes
            continue
          # we matched a new message, so if there's an existing one, it's time
          # to finish processing it
          if message:
            message.SetProgram(exe)
            if not self.AddMessage(message):
              # error is only set if the message we just tried to add would
              # otherwise be considered a fatal error.  Since AddMessage failed
              # (presumably the messages matched the ignore list), we reset
              # error to None
              error = None
          message = None
          if error:
            if error.Type() == "EXU":
              # Don't treat EXU as fatal, since unhandled exceptions
              # in other threads don't necessarily lead to the app to exit.
              # TODO(erikkay): verify that we still do trap exceptions that lead
              # to early termination.
              logging.warning(error.NormalizedStr(verbose=True))
              error = None
            else:
              if len(self._files) > 1:
                logging.error("Fatal error in program: %s" % error.Program())
              logging.error(error.NormalizedStr(verbose=True))
              fatal_errors += 1
              error = None
              fatal_exe = exe
              continue
          severity = m.group(1)
          line = m.group(2)
          m = PurifyAnalyze.pat_msg_type.match(line)
          if m:
            type = m.group(1)
            message = purify_message.Message(severity, type, m.group(2))
            if type == "EXU":
              error = message
          elif severity == "O":
            message = purify_message.Message(severity, purify_message.FATAL,
                                             line)
            # This is an internal Purify error, and it means that this run can't
            # be trusted and analysis should be aborted.
            error = message
          elif PurifyAnalyze.pat_instrumentation_failed.match(line):
            message = purify_message.Message(severity, purify_message.FATAL,
                                             line)
            error = message
          elif PurifyAnalyze.pat_terminate.match(line):
            message = purify_message.Message(severity, purify_message.FATAL,
                                             line)
            error = message
          elif PurifyAnalyze.pat_leak_summary.match(line):
            # TODO(erikkay): should we do sublists for MLK and MPK too?
            # Maybe that means we need to handle "new" and "all" messages
            # separately.
            #self._BeginNewSublist("MLK")
            #self._BeginNewSublist("MPK")
            pass
          elif PurifyAnalyze.pat_miu_summary.match(line):
            # Each time Purify is asked to do generate a list of all memory in use
            # or new memory in use, it first emits this summary line.  Since the
            # different lists can overlap, we need to tell MessageList to begin
            # a new sublist.
            # TODO(erikkay): should we tag "new" and "all" sublists explicitly
            # somehow?
            self._BeginNewSublist("MIU")
          elif PurifyAnalyze.pat_starting.match(line):
            m = PurifyAnalyze.pat_starting.match(line)
            exe = m.group(1)
            last_slash = exe.rfind("\\")
            if not purify_message.Stack.source_dir:
              path = os.path.abspath(os.path.join(exe[:last_slash], "..", ".."))
              purify_message.Stack.SetSourceDir(path)
            if not self._name:
              self._name = exe[(last_slash+1):]
          else:
            unknown = True
            for pat in PurifyAnalyze.pat_ignore:
              if pat.match(line):
                unknown = False
                break
            if unknown:
              logging.error("unknown line " + line)
        else:
          if message:
            message.AddLine(line)
          elif PurifyAnalyze.pat_arguments.match(line):
            m = PurifyAnalyze.pat_arguments.match(line)
            exe += " " + m.group(1)

      # Purify output should never end with a real message
      if message:
        logging.error("Unexpected message at end of file %s" % file)

    return fatal_errors == 0

  def GetMessageList(self, key):
    if key in self._message_lists:
      return self._message_lists[key]
    else:
      return None

  def Summary(self, echo=None, save=True):
    ''' Print a summary of how many messages of each type were found. '''
    # make sure everyone else is done first
    sys.stderr.flush()
    sys.stdout.flush()
    if echo == None:
      echo = self._echo
    if save:
      filename = os.path.join(self._report_dir, "Summary.txt")
      file = open(filename, "w")
    else:
      file = None
    logging.info("summary of Purify messages:")
    self._ReportFixableMessages()
    for key in self._message_lists:
      list = self._message_lists[key]
      unique = list.UniqueMessages()
      all = list.AllMessages()
      count = 0
      for msg in all:
        count += msg._count
      self._PrintAndSave("%s(%s) unique:%d total:%d" % (self._name,
          purify_message.GetMessageType(key), len(unique), count), file)
      if key not in ["MIU"]:
        ignore_file = "%s_%s_ignore.txt" % (self._name, key)
        ignore_hashes = self._MessageHashesFromFile(ignore_file)
        ignored = 0

        groups = list.UniqueMessageGroups()
        group_keys = groups.keys()
        group_keys.sort(cmp=lambda x,y: len(groups[y]) - len(groups[x]))
        for group in group_keys:
          # filter out ignored messages
          kept_msgs= [x for x in groups[group] if hash(x) not in ignore_hashes]
          ignored += len(groups[group]) - len(kept_msgs)
          groups[group] = kept_msgs
        if ignored:
          self._PrintAndSave("%s(%s) ignored:%d" % (self._name,
            purify_message.GetMessageType(key), ignored), file)
        total = reduce(lambda x, y: x + len(groups[y]), group_keys, 0)
        if total:
          self._PrintAndSave("%s(%s) group summary:" % (self._name,
            purify_message.GetMessageType(key)), file)
          self._PrintAndSave("   TOTAL: %d" % total, file)
          for group in group_keys:
            if len(groups[group]):
              self._PrintAndSave("   %s: %d" % (group, len(groups[group])),
                                 file)
        if echo:
          for group in group_keys:
            msgs = groups[group]
            if len(msgs) == 0:
              continue
            self._PrintAndSave("messages from %s (%d)" % (group, len(msgs)),
                               file)
            self._PrintAndSave("="*79, file)
            for msg in msgs:
              # for the summary output, line numbers are useful
              self._PrintAndSave(msg.NormalizedStr(verbose=True), file)
        # make sure stdout is flushed to avoid weird overlaps with logging
        sys.stdout.flush()
    if file:
      file.close()

  def PrintMemoryInUse(self, byte_filter=16384):
    ''' Print one or more trees showing a hierarchy of memory allocations.
    Args:
      byte_filter: a number of bytes below which we'll prune the tree
    '''
    list = self.GetMessageList("MIU")
    sublists = list.GetSublists()
    if not sublists:
      sublists = [list]
    trees = []
    summaries = []
    # create the trees and summaries
    for sublist in sublists:
      tree = MemoryTreeNode.CreateTree(sublist)
      trees.append(tree)

      # while the tree is a hierarchical assignment from the root/bottom of the
      # stack down, the summary is simply adding the total of the top-most
      # stack item from our code
      summary = {}
      total = 0
      summaries.append(summary)
      for msg in sublist.AllMessages():
        total += msg._bytes
        stack = msg.GetAllocStack()
        if stack._all_external:
          alloc_caller = "WINDOWS"
        else:
          lines = stack.GetLines()
          for line in lines:
            alloc_caller = line["function"]
            if alloc_caller:
              break
        summary[alloc_caller] = summary.get(alloc_caller, 0) + msg._bytes
      summary["TOTAL"] = total

    # print out the summaries and trees.
    # TODO(erikkay): perhaps we should be writing this output to a file
    # instead?
    tree_number = 1
    num_trees = len(trees)
    for tree, summary in zip(trees, summaries):
      print "MEMORY SNAPSHOT %d of %d" % (tree_number, num_trees)
      lines = summary.keys()
      lines.sort(cmp=lambda x,y: summary[y] - summary[x])
      rest = 0
      for line in lines:
        bytes = summary[line]
        if bytes < byte_filter:
          rest += bytes
        else:
          print "%d: %s" % (bytes, line)
      print "%d: REST" % rest
      print
      print "BEGIN TREE"
      tree.PrintRecursive(byte_filter=byte_filter)
      tree_number += 1

    # make sure stdout is flushed to avoid weird overlaps with logging
    sys.stdout.flush()

  def BugReport(self, save=True):
    ''' Print a summary of how many messages of each type were found and write
    to BugReport.txt
    '''
    if save:
      filename = os.path.join(self._report_dir, "BugReport.txt")
      file = open(filename, "w")
    else:
      file = None
    # make sure everyone else is done first
    sys.stderr.flush()
    sys.stdout.flush()
    logging.info("summary of Purify bugs:")

    # This is a specialized set of counters for unit tests, with some
    # unfortunate hard-coded knowledge.
    test_counts = {}
    total_count = 0
    for key in self._message_lists:
      bug = {}
      list = self._message_lists[key]
      unique = list.UniqueMessages()
      all = list.AllMessages()
      count = 0
      for msg in all:
        if msg._title not in bug:
          # use a single sample message to represent all messages
          # that match this title
          bug[msg._title] = {"message":msg,
                             "total":0,
                             "count":0,
                             "programs":set()}
        total_count += 1
        this_bug = bug[msg._title]
        this_bug["total"] += msg._count
        this_bug["count"] += 1
        prog = msg.Program()
        if self._name == "layout":
          # For layout tests, use the last argument, which is the URL that's
          # passed into test_shell.
          this_bug["programs"].add(prog)
          prog_args = prog.split(" ")
          if len(prog_args):
            path = prog_args[-1].replace('\\', '/')
            index = path.rfind("layout_tests/")
            if index >= 0:
              path = path[(index + len("layout_tests/")):]
            else:
              index = path.rfind("127.0.0.1:")
              if index >= 0:
                # the port number is 8000 or 9000, but length is the same
                path = "http: " + path[(index + len("127.0.0.1:8000/")):]
            count = 1 + test_counts.get(path, 0)
            test_counts[path] = count
        elif self._name == "ui":
          # ui_tests.exe appends a --test-name= argument to chrome.exe
          prog_args = prog.split(" ")
          arg_prefix = "--test-name="
          test_name = "UNKNOWN"
          for arg in prog_args:
            index = arg.find(arg_prefix)
            if index >= 0:
              test_name = arg[len(arg_prefix):]
              count = 1 + test_counts.get(test_name, 0)
              test_counts[test_name] = count
              break
          this_bug["programs"].add(test_name)
        else:
          this_bug["programs"].add(prog)

      for title in bug:
        b = bug[title]
        self._PrintAndSave("[%s] %s" % (key, title), file)
        self._PrintAndSave("%d tests, %d stacks, %d instances" % (
            len(b["programs"]), b["count"], b["total"]), file)
        self._PrintAndSave("Reproducible with:", file)
        for program in b["programs"]:
          self._PrintAndSave("   %s" % program, file)
        self._PrintAndSave("Sample error details:", file)
        self._PrintAndSave("=====================", file)
        self._PrintAndSave(b["message"].NormalizedStr(verbose=True), file)
    if len(test_counts):
      self._PrintAndSave("", file)
      self._PrintAndSave("test error counts", file)
      self._PrintAndSave("========================", file)
      tests = test_counts.keys()
      tests.sort()
      for test in tests:
        self._PrintAndSave("%s: %d" % (test, test_counts[test]), file)
    if total_count == 0:
      self._PrintAndSave("No bugs.  Shocking, I know.", file)
    # make sure stdout is flushed to avoid weird overlaps with logging
    sys.stdout.flush()
    if file:
      file.close()

  def SaveStrings(self, string_list, key, fname_extra=""):
    '''Output a list of strings to a file in the report dir.
    '''
    out = os.path.join(self._report_dir,
                       "%s_%s%s.txt" % (self._name, key, fname_extra))
    logging.info("saving %s" % (out))
    try:
      f = open(out, "w+")
      f.write('\n'.join(string_list))
    except IOError, (errno, strerror):
      logging.error("error writing to file %s (%d, %s)" % out, errno, strerror)
    if f:
      f.close()
    return True

  def SaveResults(self, path=None, verbose=False):
    ''' Output normalized data to baseline files for future comparison runs.
    Messages are saved in sorted order into a separate file for each message
    type.  See Message.NormalizedStr() for details of what's written.
    '''
    if not path:
      path = self._report_dir
    for key in self._message_lists:
      out = os.path.join(path, "%s_%s.txt" % (self._name, key))
      logging.info("saving %s" % (out))
      f = open(out, "w+")
      list = self._message_lists[key].UniqueMessages()
      # TODO(erikkay): should the count of each message be a diff factor?
      # (i.e. the same error shows up, but more frequently)
      for message in list:
        f.write(message.NormalizedStr(verbose=verbose))
        f.write("\n")
      f.close()
    return True

  def _PrintAndSave(self, msg, file):
    ''' Print |msg| to both stdout and to file. '''
    if file:
      file.write(msg + "\n")
    print msg
    sys.stdout.flush()

  def _ReportFixableMessages(self):
    ''' Collects all baseline files for the executable being tested, including
    lists of flakey results, and logs the total number of messages in them.
    '''
    # TODO(pamg): As long as we're looking at all the files, we could use the
    # information to report any message types that no longer happen at all.
    fixable = 0
    flakey = 0
    paths = [os.path.join(self._data_dir, x)
             for x in os.listdir(self._data_dir)]
    for path in paths:
      # We only care about this executable's files, and not its gtest filters.
      if (not os.path.basename(path).startswith(self._name) or
          not path.endswith(".txt") or
          path.endswith("gtest.txt") or
          path.endswith("_ignore.txt") or
          not os.path.isfile(path)):
        continue
      msgs = self._MessageHashesFromFile(path)
      if path.find("flakey") == -1:
        fixable += len(msgs)
      else:
        flakey += len(msgs)

    logging.info("Fixable errors: %s" % fixable)
    logging.info("Flakey errors: %s" % flakey)

  def _MessageHashesFromFile(self, filename):
    ''' Reads a file of normalized messages (see SaveResults) and creates a
    dictionary mapping the hash of each message to its text.
    '''
    # NOTE: this uses the same hashing algorithm as Message.__hash__.
    # Unfortunately, we can't use the same code easily since Message is based
    # on parsing an original Purify output file and this code is reading a file
    # of already normalized messages.  This means that these two bits of code
    # need to be kept in sync.
    msgs = {}
    if not os.path.isabs(filename):
      filename = os.path.join(self._data_dir, filename)
    if os.path.exists(filename):
      logging.info("reading messages from %s" % filename)
      file = open(filename, "r")
      msg = ""
      title = None
      lines = file.readlines()
      # in case the file doesn't end in a blank line
      lines.append("\n")
      for line in lines:
        # allow these files to have comments in them
        if line.startswith('#') or line.startswith('//'):
          continue
        if not title:
          if not line.isspace():
            # first line of each message is a title
            title = line
          continue
        elif not line.isspace():
          msg += line
        else:
          # note that the hash doesn't include the title, see Message.__hash__
          h = hash(msg)
          msgs[h] = title + msg
          title = None
          msg = ""
      logging.info("%s: %d msgs" % (filename, len(msgs)))
    return msgs

  def _SaveGroupSummary(self, message_list):
    '''Save a summary of message groups and their counts to a file in report_dir
    '''
    string_list = []
    groups = message_list.UniqueMessageGroups()
    group_keys = groups.keys()

    group_keys.sort(cmp=lambda x,y: len(groups[y]) - len(groups[x]))
    for group in group_keys:
      string_list.append("%s: %d" % (group, len(groups[group])))

    self.SaveStrings(string_list, message_list.GetType(), "_GROUPS")

  def CompareResults(self):
    ''' Compares the results from the current run with the baseline data
    stored in data/<name>_<key>.txt returning False if it finds new errors
    that are not in the baseline.  See ReadFile() and SaveResults() for
    details of what's in the original file and what's in the baseline.
    Errors that show up in the baseline but not the current run are not
    considered errors (they're considered "fixed"), but they do suggest
    that the baseline file could be re-generated.'''
    errors = 0
    fixes = 0
    for type in purify_message.message_type:
      if type in ["MIU"]:
        continue
      # number of new errors for this message type
      type_errors = []
      # number of new unexpected fixes for this message type
      type_fixes = []
      # the messages from the current run that are in the baseline
      new_baseline = []
      # a common prefix used to describe the program being analyzed and the
      # type of message which is used to generate filenames and descriptive
      # error messages
      type_name = "%s_%s" % (self._name, type)

      # open the baseline file to compare against
      baseline_file = "%s.txt" % type_name
      baseline_hashes = self._MessageHashesFromFile(baseline_file)

      # read the flakey file if it exists
      flakey_file = "%s_flakey.txt" % type_name
      flakey_hashes = self._MessageHashesFromFile(flakey_file)

      # read the ignore file if it exists
      ignore_file = "%s_ignore.txt" % type_name
      ignore_hashes = self._MessageHashesFromFile(ignore_file)

      # messages from the current run
      current_list = self.GetMessageList(type)
      if current_list:
        # Since we're looking at the list of unique messages,
        # if the number of occurrances of a given unique message
        # changes, it won't show up as an error.
        current_messages = current_list.UniqueMessages()
      else:
        current_messages = []
      current_hashes = {}
      # compute errors and new baseline
      for message in current_messages:
        msg_hash = hash(message)
        current_hashes[msg_hash] = message
        if msg_hash in ignore_hashes or msg_hash in flakey_hashes:
          continue
        if msg_hash in baseline_hashes:
          new_baseline.append(msg_hash)
          continue
        type_errors.append(msg_hash)
      # compute unexpected fixes
      for msg_hash in baseline_hashes:
        if (msg_hash not in current_hashes and
            msg_hash not in ignore_hashes and
            msg_hash not in flakey_hashes):
          type_fixes.append(baseline_hashes[msg_hash])

      if len(current_messages) or len(type_errors) or len(type_fixes):
        logging.info("%d '%s(%s)' messages "
                     "(%d new, %d unexpectedly fixed)" % (len(current_messages),
                     purify_message.GetMessageType(type), type,
                     len(type_errors), len(type_fixes)))

      if len(type_errors):
        strs = [current_hashes[x].NormalizedStr(verbose=True)
                for x in type_errors]
        logging.error("%d new '%s(%s)' errors found\n%s" % (len(type_errors),
                      purify_message.GetMessageType(type), type,
                      '\n'.join(strs)))
        strs = [current_hashes[x].NormalizedStr() for x in type_errors]
        self.SaveStrings(strs, type, "_NEW")
        errors += len(type_errors)

      if len(type_fixes):
        # we don't have access to the original message, so all we can do is log
        # the non-verbose normalized text
        logging.warning("%d new '%s(%s)' unexpected fixes found\n%s" % (
                        len(type_fixes), purify_message.GetMessageType(type),
                        type, '\n'.join(type_fixes)))
        self.SaveStrings(type_fixes, type, "_FIXED")
        fixes += len(type_fixes)
        if len(current_messages) == 0:
          logging.warning("all errors fixed in %s" % baseline_file)

      if len(type_fixes) or len(type_errors):
        strs = [baseline_hashes[x] for x in new_baseline]
        self.SaveStrings(strs, type, "_BASELINE")

      if current_list:
        self._SaveGroupSummary(current_list)

    if errors:
      logging.error("%d total new errors found" % errors)
      return -1
    else:
      logging.info("no new errors found - yay!")
      if fixes:
        logging.warning("%d total errors unexpectedly fixed" % fixes)
        # magic return code to turn the builder orange (via ReturnCodeCommand)
        return 88
    return 0


# The following code is here for testing and development purposes.

def _main():
  retcode = 0

  parser = optparse.OptionParser("usage: %prog [options] <files to analyze>")
  parser.add_option("-b", "--baseline", action="store_true", default=False,
                    help="save output to baseline files")
  parser.add_option("-m", "--memory_in_use",
                    action="store_true", default=False,
                    help="print memory in use summary")
  parser.add_option("", "--validate",
                    action="store_true", default=False,
                    help="validate results vs. baseline")
  parser.add_option("-e", "--echo_to_stdout",
                    action="store_true", default=False,
                    help="echo purify output to standard output")
  parser.add_option("", "--source_dir",
                    help="path to top of source tree for this build"
                    "(used to normalize source paths in output)")
  parser.add_option("", "--byte_filter", default=16384,
                    help="prune the tree below this number of bytes")
  parser.add_option("-n", "--name",
                    help="name of the test being run "
                         "(used for output filenames)")
  parser.add_option("", "--data_dir",
                    help="path to where purify data files live")
  parser.add_option("", "--bug_report", default=False,
                    action="store_true",
                    help="print output as an attempted summary of bugs")
  parser.add_option("-v", "--verbose", action="store_true", default=False,
                    help="verbose output - enable debug log messages")
  parser.add_option("", "--report_dir",
                    help="path where report files are saved")

  (options, args) = parser.parse_args()
  if not len(args) >= 1:
    parser.error("no filename specified")
  filenames = args

  if options.verbose:
    google.logging_utils.config_root(level=logging.DEBUG)
  else:
    google.logging_utils.config_root(level=logging.INFO)
  pa = PurifyAnalyze(filenames, options.echo_to_stdout, options.name,
                     options.source_dir, options.data_dir, options.report_dir)
  execute_crash = not pa.ReadFile()
  if options.bug_report:
    pa.BugReport()
    pa.Summary(False)
  elif options.memory_in_use:
    pa.PrintMemoryInUse(int(options.byte_filter))
  elif execute_crash:
    retcode = -1
    logging.error("Fatal error during test execution.  Analysis skipped.")
  elif options.validate:
    if pa.CompareResults() != 0:
      retcode = -1
      pa.SaveResults()
    pa.Summary()
  elif options.baseline:
    if not pa.SaveResults(verbose=True):
      retcode = -1
    pa.Summary(False)
  else:
    pa.Summary(False)

  sys.exit(retcode)

if __name__ == "__main__":
  _main()


