summaryrefslogtreecommitdiff
path: root/chromium/tools/code_coverage/croc.py
diff options
context:
space:
mode:
authorZeno Albisser <zeno.albisser@digia.com>2013-08-15 21:46:11 +0200
committerZeno Albisser <zeno.albisser@digia.com>2013-08-15 21:46:11 +0200
commit679147eead574d186ebf3069647b4c23e8ccace6 (patch)
treefc247a0ac8ff119f7c8550879ebb6d3dd8d1ff69 /chromium/tools/code_coverage/croc.py
downloadqtwebengine-chromium-679147eead574d186ebf3069647b4c23e8ccace6.tar.gz
Initial import.
Diffstat (limited to 'chromium/tools/code_coverage/croc.py')
-rwxr-xr-xchromium/tools/code_coverage/croc.py722
1 files changed, 722 insertions, 0 deletions
diff --git a/chromium/tools/code_coverage/croc.py b/chromium/tools/code_coverage/croc.py
new file mode 100755
index 00000000000..1b9908a5f89
--- /dev/null
+++ b/chromium/tools/code_coverage/croc.py
@@ -0,0 +1,722 @@
+#!/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))