普通文本  |  723行  |  22.47 KB

#!/usr/bin/env python
# Copyright (c) 2012 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.

"""Crocodile - compute coverage numbers for Chrome coverage dashboard."""

import optparse
import os
import platform
import re
import sys
import croc_html
import croc_scan


class CrocError(Exception):
  """Coverage error."""


class CrocStatError(CrocError):
  """Error evaluating coverage stat."""

#------------------------------------------------------------------------------


class CoverageStats(dict):
  """Coverage statistics."""

  # Default dictionary values for this stat.
  DEFAULTS = { 'files_covered': 0,
               'files_instrumented': 0,
               'files_executable': 0,
               'lines_covered': 0,
               'lines_instrumented': 0,
               'lines_executable': 0 }

  def Add(self, coverage_stats):
    """Adds a contribution from another coverage stats dict.

    Args:
      coverage_stats: Statistics to add to this one.
    """
    for k, v in coverage_stats.iteritems():
      if k in self:
        self[k] += v
      else:
        self[k] = v

  def AddDefaults(self):
    """Add some default stats which might be assumed present.

    Do not clobber if already present.  Adds resilience when evaling a
    croc file which expects certain stats to exist."""
    for k, v in self.DEFAULTS.iteritems():
      if not k in self:
        self[k] = v

#------------------------------------------------------------------------------


class CoveredFile(object):
  """Information about a single covered file."""

  def __init__(self, filename, **kwargs):
    """Constructor.

    Args:
      filename: Full path to file, '/'-delimited.
      kwargs: Keyword args are attributes for file.
    """
    self.filename = filename
    self.attrs = dict(kwargs)

    # Move these to attrs?
    self.local_path = None      # Local path to file
    self.in_lcov = False        # Is file instrumented?

    # No coverage data for file yet
    self.lines = {}     # line_no -> None=executable, 0=instrumented, 1=covered
    self.stats = CoverageStats()

  def UpdateCoverage(self):
    """Updates the coverage summary based on covered lines."""
    exe = instr = cov = 0
    for l in self.lines.itervalues():
      exe += 1
      if l is not None:
        instr += 1
        if l == 1:
          cov += 1

    # Add stats that always exist
    self.stats = CoverageStats(lines_executable=exe,
                               lines_instrumented=instr,
                               lines_covered=cov,
                               files_executable=1)

    # Add conditional stats
    if cov:
      self.stats['files_covered'] = 1
    if instr or self.in_lcov:
      self.stats['files_instrumented'] = 1

#------------------------------------------------------------------------------


class CoveredDir(object):
  """Information about a directory containing covered files."""

  def __init__(self, dirpath):
    """Constructor.

    Args:
      dirpath: Full path of directory, '/'-delimited.
    """
    self.dirpath = dirpath

    # List of covered files directly in this dir, indexed by filename (not
    # full path)
    self.files = {}

    # List of subdirs, indexed by filename (not full path)
    self.subdirs = {}

    # Dict of CoverageStats objects summarizing all children, indexed by group
    self.stats_by_group = {'all': CoverageStats()}
    # TODO: by language

  def GetTree(self, indent=''):
    """Recursively gets stats for the directory and its children.

    Args:
      indent: indent prefix string.

    Returns:
      The tree as a string.
    """
    dest = []

    # Compile all groupstats
    groupstats = []
    for group in sorted(self.stats_by_group):
      s = self.stats_by_group[group]
      if not s.get('lines_executable'):
        continue        # Skip groups with no executable lines
      groupstats.append('%s:%d/%d/%d' % (
          group, s.get('lines_covered', 0),
          s.get('lines_instrumented', 0),
          s.get('lines_executable', 0)))

    outline = '%s%-30s   %s' % (indent,
                                os.path.split(self.dirpath)[1] + '/',
                                '   '.join(groupstats))
    dest.append(outline.rstrip())

    for d in sorted(self.subdirs):
      dest.append(self.subdirs[d].GetTree(indent=indent + '  '))

    return '\n'.join(dest)

#------------------------------------------------------------------------------


class Coverage(object):
  """Code coverage for a group of files."""

  def __init__(self):
    """Constructor."""
    self.files = {}             # Map filename --> CoverageFile
    self.root_dirs = []         # (root, altname)
    self.rules = []             # (regexp, dict of RHS attrs)
    self.tree = CoveredDir('')
    self.print_stats = []       # Dicts of args to PrintStat()

    # Functions which need to be replaced for unit testing
    self.add_files_walk = os.walk         # Walk function for AddFiles()
    self.scan_file = croc_scan.ScanFile   # Source scanner for AddFiles()

  def CleanupFilename(self, filename):
    """Cleans up a filename.

    Args:
      filename: Input filename.

    Returns:
      The cleaned up filename.

    Changes all path separators to '/'.
    Makes relative paths (those starting with '../' or './' absolute.
    Replaces all instances of root dirs with alternate names.
    """
    # Change path separators
    filename = filename.replace('\\', '/')

    # Windows doesn't care about case sensitivity.
    if platform.system() in ['Windows', 'Microsoft']:
      filename = filename.lower()

    # If path is relative, make it absolute
    # TODO: Perhaps we should default to relative instead, and only understand
    # absolute to be files starting with '\', '/', or '[A-Za-z]:'?
    if filename.split('/')[0] in ('.', '..'):
      filename = os.path.abspath(filename).replace('\\', '/')

    # Replace alternate roots
    for root, alt_name in self.root_dirs:
      # Windows doesn't care about case sensitivity.
      if platform.system() in ['Windows', 'Microsoft']:
        root = root.lower()
      filename = re.sub('^' + re.escape(root) + '(?=(/|$))',
                        alt_name, filename)
    return filename

  def ClassifyFile(self, filename):
    """Applies rules to a filename, to see if we care about it.

    Args:
      filename: Input filename.

    Returns:
      A dict of attributes for the file, accumulated from the right hand sides
          of rules which fired.
    """
    attrs = {}

    # Process all rules
    for regexp, rhs_dict in self.rules:
      if regexp.match(filename):
        attrs.update(rhs_dict)

    return attrs
    # TODO: Files can belong to multiple groups?
    #   (test/source)
    #   (mac/pc/win)
    #   (media_test/all_tests)
    #   (small/med/large)
    # How to handle that?

  def AddRoot(self, root_path, alt_name='_'):
    """Adds a root directory.

    Args:
      root_path: Root directory to add.
      alt_name: If specified, name of root dir.  Otherwise, defaults to '_'.

    Raises:
      ValueError: alt_name was blank.
    """
    # Alt name must not be blank.  If it were, there wouldn't be a way to
    # reverse-resolve from a root-replaced path back to the local path, since
    # '' would always match the beginning of the candidate filename, resulting
    # in an infinite loop.
    if not alt_name:
      raise ValueError('AddRoot alt_name must not be blank.')

    # Clean up root path based on existing rules
    self.root_dirs.append([self.CleanupFilename(root_path), alt_name])

  def AddRule(self, path_regexp, **kwargs):
    """Adds a rule.

    Args:
      path_regexp: Regular expression to match for filenames.  These are
          matched after root directory replacement.
      kwargs: Keyword arguments are attributes to set if the rule applies.

    Keyword arguments currently supported:
      include: If True, includes matches; if False, excludes matches.  Ignored
          if None.
      group: If not None, sets group to apply to matches.
      language: If not None, sets file language to apply to matches.
    """

    # Compile regexp ahead of time
    self.rules.append([re.compile(path_regexp), dict(kwargs)])

  def GetCoveredFile(self, filename, add=False):
    """Gets the CoveredFile object for the filename.

    Args:
      filename: Name of file to find.
      add: If True, will add the file if it's not present.  This applies the
          transformations from AddRoot() and AddRule(), and only adds the file
          if a rule includes it, and it has a group and language.

    Returns:
      The matching CoveredFile object, or None if not present.
    """
    # Clean filename
    filename = self.CleanupFilename(filename)

    # Check for existing match
    if filename in self.files:
      return self.files[filename]

    # File isn't one we know about.  If we can't add it, give up.
    if not add:
      return None

    # Check rules to see if file can be added.  Files must be included and
    # have a group and language.
    attrs = self.ClassifyFile(filename)
    if not (attrs.get('include')
            and attrs.get('group')
            and attrs.get('language')):
      return None

    # Add the file
    f = CoveredFile(filename, **attrs)
    self.files[filename] = f

    # Return the newly covered file
    return f

  def RemoveCoveredFile(self, cov_file):
    """Removes the file from the covered file list.

    Args:
      cov_file: A file object returned by GetCoveredFile().
    """
    self.files.pop(cov_file.filename)

  def ParseLcovData(self, lcov_data):
    """Adds coverage from LCOV-formatted data.

    Args:
      lcov_data: An iterable returning lines of data in LCOV format.  For
          example, a file or list of strings.
    """
    cov_file = None
    cov_lines = None
    for line in lcov_data:
      line = line.strip()
      if line.startswith('SF:'):
        # Start of data for a new file; payload is filename
        cov_file = self.GetCoveredFile(line[3:], add=True)
        if cov_file:
          cov_lines = cov_file.lines
          cov_file.in_lcov = True       # File was instrumented
      elif not cov_file:
        # Inside data for a file we don't care about - so skip it
        pass
      elif line.startswith('DA:'):
        # Data point - that is, an executable line in current file
        line_no, is_covered = map(int, line[3:].split(','))
        if is_covered:
          # Line is covered
          cov_lines[line_no] = 1
        elif cov_lines.get(line_no) != 1:
          # Line is not covered, so track it as uncovered
          cov_lines[line_no] = 0
      elif line == 'end_of_record':
        cov_file.UpdateCoverage()
        cov_file = None
      # (else ignore other line types)

  def ParseLcovFile(self, input_filename):
    """Adds coverage data from a .lcov file.

    Args:
      input_filename: Input filename.
    """
    # TODO: All manner of error checking
    lcov_file = None
    try:
      lcov_file = open(input_filename, 'rt')
      self.ParseLcovData(lcov_file)
    finally:
      if lcov_file:
        lcov_file.close()

  def GetStat(self, stat, group='all', default=None):
    """Gets a statistic from the coverage object.

    Args:
      stat: Statistic to get.  May also be an evaluatable python expression,
          using the stats.  For example, 'stat1 - stat2'.
      group: File group to match; if 'all', matches all groups.
      default: Value to return if there was an error evaluating the stat.  For
          example, if the stat does not exist.  If None, raises
          CrocStatError.

    Returns:
      The evaluated stat, or None if error.

    Raises:
      CrocStatError: Error evaluating stat.
    """
    # TODO: specify a subdir to get the stat from, then walk the tree to
    # print the stats from just that subdir

    # Make sure the group exists
    if group not in self.tree.stats_by_group:
      if default is None:
        raise CrocStatError('Group %r not found.' % group)
      else:
        return default

    stats = self.tree.stats_by_group[group]
    # Unit tests use real dicts, not CoverageStats objects,
    # so we can't AddDefaults() on them.
    if group == 'all' and hasattr(stats, 'AddDefaults'):
      stats.AddDefaults()
    try:
      return eval(stat, {'__builtins__': {'S': self.GetStat}}, stats)
    except Exception, e:
      if default is None:
        raise CrocStatError('Error evaluating stat %r: %s' % (stat, e))
      else:
        return default

  def PrintStat(self, stat, format=None, outfile=sys.stdout, **kwargs):
    """Prints a statistic from the coverage object.

    Args:
      stat: Statistic to get.  May also be an evaluatable python expression,
          using the stats.  For example, 'stat1 - stat2'.
      format: Format string to use when printing stat.  If None, prints the
          stat and its evaluation.
      outfile: File stream to output stat to; defaults to stdout.
      kwargs: Additional args to pass to GetStat().
    """
    s = self.GetStat(stat, **kwargs)
    if format is None:
      outfile.write('GetStat(%r) = %s\n' % (stat, s))
    else:
      outfile.write(format % s + '\n')

  def AddFiles(self, src_dir):
    """Adds files to coverage information.

    LCOV files only contains files which are compiled and instrumented as part
    of running coverage.  This function finds missing files and adds them.

    Args:
      src_dir: Directory on disk at which to start search.  May be a relative
          path on disk starting with '.' or '..', or an absolute path, or a
          path relative to an alt_name for one of the roots
          (for example, '_/src').  If the alt_name matches more than one root,
          all matches will be attempted.

    Note that dirs not underneath one of the root dirs and covered by an
    inclusion rule will be ignored.
    """
    # Check for root dir alt_names in the path and replace with the actual
    # root dirs, then recurse.
    found_root = False
    for root, alt_name in self.root_dirs:
      replaced_root = re.sub('^' + re.escape(alt_name) + '(?=(/|$))', root,
                             src_dir)
      if replaced_root != src_dir:
        found_root = True
        self.AddFiles(replaced_root)
    if found_root:
      return    # Replaced an alt_name with a root_dir, so already recursed.

    for (dirpath, dirnames, filenames) in self.add_files_walk(src_dir):
      # Make a copy of the dirnames list so we can modify the original to
      # prune subdirs we don't need to walk.
      for d in list(dirnames):
        # Add trailing '/' to directory names so dir-based regexps can match
        # '/' instead of needing to specify '(/|$)'.
        dpath = self.CleanupFilename(dirpath + '/' + d) + '/'
        attrs = self.ClassifyFile(dpath)
        if not attrs.get('include'):
          # Directory has been excluded, so don't traverse it
          # TODO: Document the slight weirdness caused by this: If you
          # AddFiles('./A'), and the rules include 'A/B/C/D' but not 'A/B',
          # then it won't recurse into './A/B' so won't find './A/B/C/D'.
          # Workarounds are to AddFiles('./A/B/C/D') or AddFiles('./A/B/C').
          # The latter works because it explicitly walks the contents of the
          # path passed to AddFiles(), so it finds './A/B/C/D'.
          dirnames.remove(d)

      for f in filenames:
        local_path = dirpath + '/' + f

        covf = self.GetCoveredFile(local_path, add=True)
        if not covf:
          continue

        # Save where we found the file, for generating line-by-line HTML output
        covf.local_path = local_path

        if covf.in_lcov:
          # File already instrumented and doesn't need to be scanned
          continue

        if not covf.attrs.get('add_if_missing', 1):
          # Not allowed to add the file
          self.RemoveCoveredFile(covf)
          continue

        # Scan file to find potentially-executable lines
        lines = self.scan_file(covf.local_path, covf.attrs.get('language'))
        if lines:
          for l in lines:
            covf.lines[l] = None
          covf.UpdateCoverage()
        else:
          # File has no executable lines, so don't count it
          self.RemoveCoveredFile(covf)

  def AddConfig(self, config_data, lcov_queue=None, addfiles_queue=None):
    """Adds JSON-ish config data.

    Args:
      config_data: Config data string.
      lcov_queue: If not None, object to append lcov_files to instead of
          parsing them immediately.
      addfiles_queue: If not None, object to append add_files to instead of
          processing them immediately.
    """
    # TODO: All manner of error checking
    cfg = eval(config_data, {'__builtins__': {}}, {})

    for rootdict in cfg.get('roots', []):
      self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '_'))

    for ruledict in cfg.get('rules', []):
      regexp = ruledict.pop('regexp')
      self.AddRule(regexp, **ruledict)

    for add_lcov in cfg.get('lcov_files', []):
      if lcov_queue is not None:
        lcov_queue.append(add_lcov)
      else:
        self.ParseLcovFile(add_lcov)

    for add_path in cfg.get('add_files', []):
      if addfiles_queue is not None:
        addfiles_queue.append(add_path)
      else:
        self.AddFiles(add_path)

    self.print_stats += cfg.get('print_stats', [])

  def ParseConfig(self, filename, **kwargs):
    """Parses a configuration file.

    Args:
      filename: Config filename.
      kwargs: Additional parameters to pass to AddConfig().
    """
    # TODO: All manner of error checking
    f = None
    try:
      f = open(filename, 'rt')
      # Need to strip CR's from CRLF-terminated lines or posix systems can't
      # eval the data.
      config_data = f.read().replace('\r\n', '\n')
      # TODO: some sort of include syntax.
      #
      # Needs to be done at string-time rather than at eval()-time, so that
      # it's possible to include parts of dicts.  Path from a file to its
      # include should be relative to the dir containing the file.
      #
      # Or perhaps it could be done after eval.  In that case, there'd be an
      # 'include' section with a list of files to include.  Those would be
      # eval()'d and recursively pre- or post-merged with the including file.
      #
      # Or maybe just don't worry about it, since multiple configs can be
      # specified on the command line.
      self.AddConfig(config_data, **kwargs)
    finally:
      if f:
        f.close()

  def UpdateTreeStats(self):
    """Recalculates the tree stats from the currently covered files.

    Also calculates coverage summary for files.
    """
    self.tree = CoveredDir('')
    for cov_file in self.files.itervalues():
      # Add the file to the tree
      fdirs = cov_file.filename.split('/')
      parent = self.tree
      ancestors = [parent]
      for d in fdirs[:-1]:
        if d not in parent.subdirs:
          if parent.dirpath:
            parent.subdirs[d] = CoveredDir(parent.dirpath + '/' + d)
          else:
            parent.subdirs[d] = CoveredDir(d)
        parent = parent.subdirs[d]
        ancestors.append(parent)
      # Final subdir actually contains the file
      parent.files[fdirs[-1]] = cov_file

      # Now add file's contribution to coverage by dir
      for a in ancestors:
        # Add to 'all' group
        a.stats_by_group['all'].Add(cov_file.stats)

        # Add to group file belongs to
        group = cov_file.attrs.get('group')
        if group not in a.stats_by_group:
          a.stats_by_group[group] = CoverageStats()
        cbyg = a.stats_by_group[group]
        cbyg.Add(cov_file.stats)

  def PrintTree(self):
    """Prints the tree stats."""
    # Print the tree
    print 'Lines of code coverage by directory:'
    print self.tree.GetTree()

#------------------------------------------------------------------------------


def Main(argv):
  """Main routine.

  Args:
    argv: list of arguments

  Returns:
    exit code, 0 for normal exit.
  """
  # Parse args
  parser = optparse.OptionParser()
  parser.add_option(
      '-i', '--input', dest='inputs', type='string', action='append',
      metavar='FILE',
      help='read LCOV input from FILE')
  parser.add_option(
      '-r', '--root', dest='roots', type='string', action='append',
      metavar='ROOT[=ALTNAME]',
      help='add ROOT directory, optionally map in coverage results as ALTNAME')
  parser.add_option(
      '-c', '--config', dest='configs', type='string', action='append',
      metavar='FILE',
      help='read settings from configuration FILE')
  parser.add_option(
      '-a', '--addfiles', dest='addfiles', type='string', action='append',
      metavar='PATH',
      help='add files from PATH to coverage data')
  parser.add_option(
      '-t', '--tree', dest='tree', action='store_true',
      help='print tree of code coverage by group')
  parser.add_option(
      '-u', '--uninstrumented', dest='uninstrumented', action='store_true',
      help='list uninstrumented files')
  parser.add_option(
      '-m', '--html', dest='html_out', type='string', metavar='PATH',
      help='write HTML output to PATH')
  parser.add_option(
      '-b', '--base_url', dest='base_url', type='string', metavar='URL',
      help='include URL in base tag of HTML output')

  parser.set_defaults(
      inputs=[],
      roots=[],
      configs=[],
      addfiles=[],
      tree=False,
      html_out=None,
  )

  options = parser.parse_args(args=argv)[0]

  cov = Coverage()

  # Set root directories for coverage
  for root_opt in options.roots:
    if '=' in root_opt:
      cov.AddRoot(*root_opt.split('='))
    else:
      cov.AddRoot(root_opt)

  # Read config files
  for config_file in options.configs:
    cov.ParseConfig(config_file, lcov_queue=options.inputs,
                    addfiles_queue=options.addfiles)

  # Parse lcov files
  for input_filename in options.inputs:
    cov.ParseLcovFile(input_filename)

  # Add missing files
  for add_path in options.addfiles:
    cov.AddFiles(add_path)

  # Print help if no files specified
  if not cov.files:
    print 'No covered files found.'
    parser.print_help()
    return 1

  # Update tree stats
  cov.UpdateTreeStats()

  # Print uninstrumented filenames
  if options.uninstrumented:
    print 'Uninstrumented files:'
    for f in sorted(cov.files):
      covf = cov.files[f]
      if not covf.in_lcov:
        print '  %-6s %-6s %s' % (covf.attrs.get('group'),
                                  covf.attrs.get('language'), f)

  # Print tree stats
  if options.tree:
    cov.PrintTree()

  # Print stats
  for ps_args in cov.print_stats:
    cov.PrintStat(**ps_args)

  # Generate HTML
  if options.html_out:
    html = croc_html.CrocHtml(cov, options.html_out, options.base_url)
    html.Write()

  # Normal exit
  return 0


if __name__ == '__main__':
  sys.exit(Main(sys.argv))