| Index: tools/code_coverage/croc.py
|
| ===================================================================
|
| --- tools/code_coverage/croc.py (revision 17130)
|
| +++ tools/code_coverage/croc.py (working copy)
|
| @@ -31,16 +31,19 @@
|
|
|
| """Crocodile - compute coverage numbers for Chrome coverage dashboard."""
|
|
|
| +import optparse
|
| import os
|
| import re
|
| import sys
|
| -from optparse import OptionParser
|
| +import croc_html
|
| +import croc_scan
|
|
|
|
|
| -class CoverageError(Exception):
|
| +class CrocError(Exception):
|
| """Coverage error."""
|
|
|
| -class CoverageStatError(CoverageError):
|
| +
|
| +class CrocStatError(CrocError):
|
| """Error evaluating coverage stat."""
|
|
|
| #------------------------------------------------------------------------------
|
| @@ -57,7 +60,7 @@
|
| """
|
| for k, v in coverage_stats.iteritems():
|
| if k in self:
|
| - self[k] = self[k] + v
|
| + self[k] += v
|
| else:
|
| self[k] = v
|
|
|
| @@ -67,18 +70,20 @@
|
| class CoveredFile(object):
|
| """Information about a single covered file."""
|
|
|
| - def __init__(self, filename, group, language):
|
| + def __init__(self, filename, **kwargs):
|
| """Constructor.
|
|
|
| Args:
|
| filename: Full path to file, '/'-delimited.
|
| - group: Group file belongs to.
|
| - language: Language for file.
|
| + kwargs: Keyword args are attributes for file.
|
| """
|
| self.filename = filename
|
| - self.group = group
|
| - self.language = language
|
| + 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()
|
| @@ -102,10 +107,9 @@
|
| # Add conditional stats
|
| if cov:
|
| self.stats['files_covered'] = 1
|
| - if instr:
|
| + if instr or self.in_lcov:
|
| self.stats['files_instrumented'] = 1
|
|
|
| -
|
| #------------------------------------------------------------------------------
|
|
|
|
|
| @@ -128,7 +132,7 @@
|
| self.subdirs = {}
|
|
|
| # Dict of CoverageStats objects summarizing all children, indexed by group
|
| - self.stats_by_group = {'all':CoverageStats()}
|
| + self.stats_by_group = {'all': CoverageStats()}
|
| # TODO: by language
|
|
|
| def GetTree(self, indent=''):
|
| @@ -154,7 +158,8 @@
|
| s.get('lines_executable', 0)))
|
|
|
| outline = '%s%-30s %s' % (indent,
|
| - self.dirpath + '/', ' '.join(groupstats))
|
| + os.path.split(self.dirpath)[1] + '/',
|
| + ' '.join(groupstats))
|
| dest.append(outline.rstrip())
|
|
|
| for d in sorted(self.subdirs):
|
| @@ -172,18 +177,14 @@
|
| """Constructor."""
|
| self.files = {} # Map filename --> CoverageFile
|
| self.root_dirs = [] # (root, altname)
|
| - self.rules = [] # (regexp, include, group, language)
|
| + self.rules = [] # (regexp, dict of RHS attrs)
|
| self.tree = CoveredDir('')
|
| self.print_stats = [] # Dicts of args to PrintStat()
|
|
|
| - self.add_files_walk = os.walk # Walk function for AddFiles()
|
| + # 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()
|
|
|
| - # Must specify subdir rule, or AddFiles() won't find any files because it
|
| - # will prune out all the subdirs. Since subdirs never match any code,
|
| - # they won't be reported in other stats, so this is ok.
|
| - self.AddRule('.*/$', language='subdir')
|
| -
|
| -
|
| def CleanupFilename(self, filename):
|
| """Cleans up a filename.
|
|
|
| @@ -208,8 +209,8 @@
|
|
|
| # Replace alternate roots
|
| for root, alt_name in self.root_dirs:
|
| - filename = re.sub('^' + re.escape(root) + '(?=(/|$))',
|
| - alt_name, filename)
|
| + filename = re.sub('^' + re.escape(root) + '(?=(/|$))',
|
| + alt_name, filename)
|
| return filename
|
|
|
| def ClassifyFile(self, filename):
|
| @@ -219,29 +220,17 @@
|
| filename: Input filename.
|
|
|
| Returns:
|
| - (None, None) if the file is not included or has no group or has no
|
| - language. Otherwise, a 2-tuple containing:
|
| - The group for the file (for example, 'source' or 'test').
|
| - The language of the file.
|
| + A dict of attributes for the file, accumulated from the right hand sides
|
| + of rules which fired.
|
| """
|
| - include = False
|
| - group = None
|
| - language = None
|
| + attrs = {}
|
|
|
| # Process all rules
|
| - for regexp, rule_include, rule_group, rule_language in self.rules:
|
| + for regexp, rhs_dict in self.rules:
|
| if regexp.match(filename):
|
| - # include/exclude source
|
| - if rule_include is not None:
|
| - include = rule_include
|
| - if rule_group is not None:
|
| - group = rule_group
|
| - if rule_language is not None:
|
| - language = rule_language
|
| + attrs.update(rhs_dict)
|
|
|
| - # TODO: Should have a debug mode which prints files which aren't excluded
|
| - # and why (explicitly excluded, no type, no language, etc.)
|
| -
|
| + return attrs
|
| # TODO: Files can belong to multiple groups?
|
| # (test/source)
|
| # (mac/pc/win)
|
| @@ -249,36 +238,43 @@
|
| # (small/med/large)
|
| # How to handle that?
|
|
|
| - # Return classification if the file is included and has a group and
|
| - # language
|
| - if include and group and language:
|
| - return group, language
|
| - else:
|
| - return None, None
|
| -
|
| - def AddRoot(self, root_path, alt_name='#'):
|
| + 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
|
| + 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, include=None, group=None, language=None):
|
| + 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), include, group, language])
|
| + self.rules.append([re.compile(path_regexp), dict(kwargs)])
|
|
|
| def GetCoveredFile(self, filename, add=False):
|
| """Gets the CoveredFile object for the filename.
|
| @@ -303,18 +299,29 @@
|
| if not add:
|
| return None
|
|
|
| - # Check rules to see if file can be added
|
| - group, language = self.ClassifyFile(filename)
|
| - if not group:
|
| + # 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, group, language)
|
| + 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.
|
|
|
| @@ -331,6 +338,7 @@
|
| 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
|
| @@ -372,13 +380,13 @@
|
| 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
|
| - CoverageStatError.
|
| + CrocStatError.
|
|
|
| Returns:
|
| The evaluated stat, or None if error.
|
|
|
| Raises:
|
| - CoverageStatError: Error evaluating stat.
|
| + 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
|
| @@ -386,16 +394,16 @@
|
| # Make sure the group exists
|
| if group not in self.tree.stats_by_group:
|
| if default is None:
|
| - raise CoverageStatError('Group %r not found.' % group)
|
| + raise CrocStatError('Group %r not found.' % group)
|
| else:
|
| return default
|
|
|
| stats = self.tree.stats_by_group[group]
|
| try:
|
| - return eval(stat, {'__builtins__':{'S':self.GetStat}}, stats)
|
| + return eval(stat, {'__builtins__': {'S': self.GetStat}}, stats)
|
| except Exception, e:
|
| if default is None:
|
| - raise CoverageStatError('Error evaluating stat %r: %s' % (stat, e))
|
| + raise CrocStatError('Error evaluating stat %r: %s' % (stat, e))
|
| else:
|
| return default
|
|
|
| @@ -426,7 +434,7 @@
|
| 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,
|
| + (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
|
| @@ -451,8 +459,8 @@
|
| # Add trailing '/' to directory names so dir-based regexps can match
|
| # '/' instead of needing to specify '(/|$)'.
|
| dpath = self.CleanupFilename(dirpath + '/' + d) + '/'
|
| - group, language = self.ClassifyFile(dpath)
|
| - if not group:
|
| + 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',
|
| @@ -463,12 +471,33 @@
|
| dirnames.remove(d)
|
|
|
| for f in filenames:
|
| - covf = self.GetCoveredFile(dirpath + '/' + f, add=True)
|
| - # TODO: scan files for executable lines. Add these to the file as
|
| - # 'executable', but not 'instrumented' or 'covered'.
|
| - # TODO: if a file has no executable lines, don't add it.
|
| - if covf:
|
| + 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.
|
| @@ -481,16 +510,14 @@
|
| processing them immediately.
|
| """
|
| # TODO: All manner of error checking
|
| - cfg = eval(config_data, {'__builtins__':{}}, {})
|
| + cfg = eval(config_data, {'__builtins__': {}}, {})
|
|
|
| for rootdict in cfg.get('roots', []):
|
| - self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '#'))
|
| + self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '_'))
|
|
|
| for ruledict in cfg.get('rules', []):
|
| - self.AddRule(ruledict['regexp'],
|
| - include=ruledict.get('include'),
|
| - group=ruledict.get('group'),
|
| - language=ruledict.get('language'))
|
| + regexp = ruledict.pop('regexp')
|
| + self.AddRule(regexp, **ruledict)
|
|
|
| for add_lcov in cfg.get('lcov_files', []):
|
| if lcov_queue is not None:
|
| @@ -511,6 +538,7 @@
|
|
|
| Args:
|
| filename: Config filename.
|
| + kwargs: Additional parameters to pass to AddConfig().
|
| """
|
| # TODO: All manner of error checking
|
| f = None
|
| @@ -519,10 +547,18 @@
|
| # 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.
|
| + # 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:
|
| @@ -531,17 +567,20 @@
|
| def UpdateTreeStats(self):
|
| """Recalculates the tree stats from the currently covered files.
|
|
|
| - Also calculates coverage summary for files."""
|
| + Also calculates coverage summary for files.
|
| + """
|
| self.tree = CoveredDir('')
|
| for cov_file in self.files.itervalues():
|
| # Add the file to the tree
|
| - # TODO: Don't really need to create the tree unless we're creating HTML
|
| fdirs = cov_file.filename.split('/')
|
| parent = self.tree
|
| ancestors = [parent]
|
| for d in fdirs[:-1]:
|
| if d not in parent.subdirs:
|
| - parent.subdirs[d] = CoveredDir(d)
|
| + 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
|
| @@ -553,9 +592,10 @@
|
| a.stats_by_group['all'].Add(cov_file.stats)
|
|
|
| # Add to group file belongs to
|
| - if cov_file.group not in a.stats_by_group:
|
| - a.stats_by_group[cov_file.group] = CoverageStats()
|
| - cbyg = a.stats_by_group[cov_file.group]
|
| + 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):
|
| @@ -577,7 +617,7 @@
|
| exit code, 0 for normal exit.
|
| """
|
| # Parse args
|
| - parser = OptionParser()
|
| + parser = optparse.OptionParser()
|
| parser.add_option(
|
| '-i', '--input', dest='inputs', type='string', action='append',
|
| metavar='FILE',
|
| @@ -600,6 +640,9 @@
|
| 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.set_defaults(
|
| inputs=[],
|
| @@ -607,9 +650,10 @@
|
| configs=[],
|
| addfiles=[],
|
| tree=False,
|
| + html_out=None,
|
| )
|
|
|
| - (options, args) = parser.parse_args()
|
| + options = parser.parse_args(args=argv)[0]
|
|
|
| cov = Coverage()
|
|
|
| @@ -647,10 +691,10 @@
|
| print 'Uninstrumented files:'
|
| for f in sorted(cov.files):
|
| covf = cov.files[f]
|
| - if not covf.stats.get('lines_instrumented'):
|
| - print ' %-6s %-6s %s' % (covf.group, covf.language, 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()
|
| @@ -659,6 +703,11 @@
|
| 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)
|
| + html.Write()
|
| +
|
| # Normal exit
|
| return 0
|
|
|
|
|