| #!/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)) |