From 0df95fd432f9023ce03548c2aba8ca4d774910f7 Mon Sep 17 00:00:00 2001 From: "rspangler@google.com" Date: Fri, 29 May 2009 00:05:27 +0000 Subject: Major refactoring of Croc. Add support for scanning missing source files for executable lines. Add support for HTML output. Now reports percent coverage. BUG=none TEST=by hand on experimental buildbot Review URL: http://codereview.chromium.org/113980 git-svn-id: svn://svn.chromium.org/chrome/trunk/src@17141 0039d316-1c4b-4281-b951-d872f2087c98 --- tools/code_coverage/croc.py | 229 ++++++++++------- tools/code_coverage/croc_html.py | 453 ++++++++++++++++++++++++++++++++++ tools/code_coverage/croc_scan.py | 191 ++++++++++++++ tools/code_coverage/croc_scan_test.py | 219 ++++++++++++++++ tools/code_coverage/croc_test.py | 343 +++++++++++++++---------- 5 files changed, 1215 insertions(+), 220 deletions(-) create mode 100644 tools/code_coverage/croc_html.py create mode 100644 tools/code_coverage/croc_scan.py create mode 100644 tools/code_coverage/croc_scan_test.py (limited to 'tools') diff --git a/tools/code_coverage/croc.py b/tools/code_coverage/croc.py index b2bd33b..9d2f3ac 100644 --- a/tools/code_coverage/croc.py +++ b/tools/code_coverage/croc.py @@ -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 @@ class CoverageStats(dict): """ for k, v in coverage_stats.iteritems(): if k in self: - self[k] = self[k] + v + self[k] += v else: self[k] = v @@ -67,17 +70,19 @@ class CoverageStats(dict): 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 @@ -102,10 +107,9 @@ class CoveredFile(object): # 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 @@ class CoveredDir(object): 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 @@ class CoveredDir(object): 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,17 +177,13 @@ class Coverage(object): """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() - - # 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') - + # 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. @@ -208,8 +209,8 @@ class Coverage(object): # 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 @@ class Coverage(object): 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 - - # TODO: Should have a debug mode which prints files which aren't excluded - # and why (explicitly excluded, no type, no language, etc.) + attrs.update(rhs_dict) + return attrs # TODO: Files can belong to multiple groups? # (test/source) # (mac/pc/win) @@ -249,36 +238,43 @@ class Coverage(object): # (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 @@ class Coverage(object): 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 @@ class Coverage(object): 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 @@ class Coverage(object): 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 @@ class Coverage(object): # 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 @@ class Coverage(object): 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 @@ class Coverage(object): # 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 @@ class Coverage(object): 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 @@ class Coverage(object): 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 @@ class Coverage(object): Args: filename: Config filename. + kwargs: Additional parameters to pass to AddConfig(). """ # TODO: All manner of error checking f = None @@ -519,10 +547,18 @@ class Coverage(object): # 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 @@ class Coverage(object): 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 @@ class Coverage(object): 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 @@ def Main(argv): 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 @@ def Main(argv): 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 @@ def Main(argv): configs=[], addfiles=[], tree=False, + html_out=None, ) - (options, args) = parser.parse_args() + options = parser.parse_args(args=argv)[0] cov = Coverage() @@ -647,9 +691,9 @@ def Main(argv): 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: @@ -659,6 +703,11 @@ def Main(argv): 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 diff --git a/tools/code_coverage/croc_html.py b/tools/code_coverage/croc_html.py new file mode 100644 index 0000000..79ec85d --- /dev/null +++ b/tools/code_coverage/croc_html.py @@ -0,0 +1,453 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Crocodile HTML output.""" + +import os +import shutil +import time +import xml.dom + + +class CrocHtmlError(Exception): + """Coverage HTML error.""" + + +class HtmlElement(object): + """Node in a HTML file.""" + + def __init__(self, doc, element): + """Constructor. + + Args: + doc: XML document object. + element: XML element. + """ + self.doc = doc + self.element = element + + def E(self, name, **kwargs): + """Adds a child element. + + Args: + name: Name of element. + kwargs: Attributes for element. To use an attribute which is a python + reserved word (i.e. 'class'), prefix the attribute name with 'e_'. + + Returns: + The child element. + """ + he = HtmlElement(self.doc, self.doc.createElement(name)) + element = he.element + self.element.appendChild(element) + + for k, v in kwargs.iteritems(): + if k.startswith('e_'): + # Remove prefix + element.setAttribute(k[2:], str(v)) + else: + element.setAttribute(k, str(v)) + + return he + + def Text(self, text): + """Adds a text node. + + Args: + text: Text to add. + + Returns: + self. + """ + t = self.doc.createTextNode(str(text)) + self.element.appendChild(t) + return self + + +class HtmlFile(object): + """HTML file.""" + + def __init__(self, xml_impl, filename): + """Constructor. + + Args: + xml_impl: DOMImplementation to use to create document. + filename: Path to file. + """ + self.xml_impl = xml_impl + doctype = xml_impl.createDocumentType( + 'HTML', '-//W3C//DTD HTML 4.01//EN', + 'http://www.w3.org/TR/html4/strict.dtd') + self.doc = xml_impl.createDocument(None, 'html', doctype) + self.filename = filename + + # Create head and body elements + root = HtmlElement(self.doc, self.doc.documentElement) + self.head = root.E('head') + self.body = root.E('body') + + def Write(self, cleanup=True): + """Writes the file. + + Args: + cleanup: If True, calls unlink() on the internal xml document. This + frees up memory, but means that you can't use this file for anything + else. + """ + f = open(self.filename, 'wt') + self.doc.writexml(f, encoding='UTF-8') + f.close() + + if cleanup: + self.doc.unlink() + # Prevent future uses of the doc now that we've unlinked it + self.doc = None + +#------------------------------------------------------------------------------ + +COV_TYPE_STRING = {None: 'm', 0: 'i', 1: 'E', 2: ' '} +COV_TYPE_CLASS = {None: 'missing', 0: 'instr', 1: 'covered', 2: ''} + + +class CrocHtml(object): + """Crocodile HTML output class.""" + + def __init__(self, cov, output_root): + """Constructor.""" + self.cov = cov + self.output_root = output_root + self.xml_impl = xml.dom.getDOMImplementation() + self.time_string = 'Coverage information generated %s.' % time.asctime() + + def CreateHtmlDoc(self, filename, title): + """Creates a new HTML document. + + Args: + filename: Filename to write to, relative to self.output_root. + title: Title of page + + Returns: + The document. + """ + f = HtmlFile(self.xml_impl, self.output_root + '/' + filename) + + f.head.E('title').Text(title) + f.head.E( + 'link', rel='stylesheet', type='text/css', + href='../' * (len(filename.split('/')) - 1) + 'croc.css') + + return f + + def AddCaptionForFile(self, body, path): + """Adds a caption for the file, with links to each parent dir. + + Args: + body: Body elemement. + path: Path to file. + """ + # This is slightly different that for subdir, because it needs to have a + # link to the current directory's index.html. + hdr = body.E('h2') + hdr.Text('Coverage for ') + dirs = [''] + path.split('/') + num_dirs = len(dirs) + for i in range(num_dirs - 1): + hdr.E('a', href=( + '../' * (num_dirs - i - 2) + 'index.html')).Text(dirs[i] + '/') + hdr.Text(dirs[-1]) + + def AddCaptionForSubdir(self, body, path): + """Adds a caption for the subdir, with links to each parent dir. + + Args: + body: Body elemement. + path: Path to subdir. + """ + # Link to parent dirs + hdr = body.E('h2') + hdr.Text('Coverage for ') + dirs = [''] + path.split('/') + num_dirs = len(dirs) + for i in range(num_dirs - 1): + hdr.E('a', href=( + '../' * (num_dirs - i - 1) + 'index.html')).Text(dirs[i] + '/') + hdr.Text(dirs[-1] + '/') + + def AddSectionHeader(self, table, caption, itemtype, is_file=False): + """Adds a section header to the coverage table. + + Args: + table: Table to add rows to. + caption: Caption for section, if not None. + itemtype: Type of items in this section, if not None. + is_file: Are items in this section files? + """ + + if caption is not None: + table.E('tr').E('td', e_class='secdesc', colspan=8).Text(caption) + + sec_hdr = table.E('tr') + + if itemtype is not None: + sec_hdr.E('td', e_class='section').Text(itemtype) + + sec_hdr.E('td', e_class='section').Text('Coverage') + sec_hdr.E('td', e_class='section', colspan=3).Text( + 'Lines executed / instrumented / missing') + + graph = sec_hdr.E('td', e_class='section') + graph.E('span', style='color:#00FF00').Text('exe') + graph.Text(' / ') + graph.E('span', style='color:#FFFF00').Text('inst') + graph.Text(' / ') + graph.E('span', style='color:#FF0000').Text('miss') + + if is_file: + sec_hdr.E('td', e_class='section').Text('Language') + sec_hdr.E('td', e_class='section').Text('Group') + else: + sec_hdr.E('td', e_class='section', colspan=2) + + def AddItem(self, table, itemname, stats, attrs, link=None): + """Adds a bar graph to the element. This is a series of elements. + + Args: + table: Table to add item to. + itemname: Name of item. + stats: Stats object. + attrs: Attributes dictionary; if None, no attributes will be printed. + link: Destination for itemname hyperlink, if not None. + """ + row = table.E('tr') + + # Add item name + if itemname is not None: + item_elem = row.E('td') + if link is not None: + item_elem = item_elem.E('a', href=link) + item_elem.Text(itemname) + + # Get stats + stat_exe = stats.get('lines_executable', 0) + stat_ins = stats.get('lines_instrumented', 0) + stat_cov = stats.get('lines_covered', 0) + + percent = row.E('td') + + # Add text + row.E('td', e_class='number').Text(stat_cov) + row.E('td', e_class='number').Text(stat_ins) + row.E('td', e_class='number').Text(stat_exe - stat_ins) + + # Add percent and graph; only fill in if there's something in there + graph = row.E('td', e_class='graph', width=100) + if stat_exe: + percent_cov = 100.0 * stat_cov / stat_exe + percent_ins = 100.0 * stat_ins / stat_exe + + # Color percent based on thresholds + percent.Text('%.1f%%' % percent_cov) + if percent_cov >= 80: + percent.element.setAttribute('class', 'high_pct') + elif percent_cov >= 60: + percent.element.setAttribute('class', 'mid_pct') + else: + percent.element.setAttribute('class', 'low_pct') + + # Graphs use integer values + percent_cov = int(percent_cov) + percent_ins = int(percent_ins) + + graph.Text('.') + graph.E('span', style='padding-left:%dpx' % percent_cov, + e_class='g_covered') + graph.E('span', style='padding-left:%dpx' % (percent_ins - percent_cov), + e_class='g_instr') + graph.E('span', style='padding-left:%dpx' % (100 - percent_ins), + e_class='g_missing') + + if attrs: + row.E('td', e_class='stat').Text(attrs.get('language')) + row.E('td', e_class='stat').Text(attrs.get('group')) + else: + row.E('td', colspan=2) + + def WriteFile(self, cov_file): + """Writes the HTML for a file. + + Args: + cov_file: croc.CoveredFile to write. + """ + print ' ' + cov_file.filename + title = 'Coverage for ' + cov_file.filename + + f = self.CreateHtmlDoc(cov_file.filename + '.html', title) + body = f.body + + # Write header section + self.AddCaptionForFile(body, cov_file.filename) + + # Summary for this file + table = body.E('table') + self.AddSectionHeader(table, None, None, is_file=True) + self.AddItem(table, None, cov_file.stats, cov_file.attrs) + + body.E('h2').Text('Line-by-line coverage:') + + # Print line-by-line coverage + if cov_file.local_path: + code_table = body.E('table').E('tr').E('td').E('pre') + + flines = open(cov_file.local_path, 'rt') + lineno = 0 + + for line in flines: + lineno += 1 + line_cov = cov_file.lines.get(lineno, 2) + e_class = COV_TYPE_CLASS.get(line_cov) + + code_table.E('span', e_class=e_class).Text('%4d %s : %s\n' % ( + lineno, + COV_TYPE_STRING.get(line_cov), + line.rstrip() + )) + + else: + body.Text('Line-by-line coverage not available. Make sure the directory' + ' containing this file has been scanned via ') + body.E('B').Text('add_files') + body.Text(' in a configuration file, or the ') + body.E('B').Text('--addfiles') + body.Text(' command line option.') + + # TODO: if file doesn't have a local path, try to find it by + # reverse-mapping roots and searching for the file. + + body.E('p', e_class='time').Text(self.time_string) + f.Write() + + def WriteSubdir(self, cov_dir): + """Writes the index.html for a subdirectory. + + Args: + cov_dir: croc.CoveredDir to write. + """ + print ' ' + cov_dir.dirpath + '/' + + # Create the subdir if it doesn't already exist + subdir = self.output_root + '/' + cov_dir.dirpath + if not os.path.exists(subdir): + os.mkdir(subdir) + + if cov_dir.dirpath: + title = 'Coverage for ' + cov_dir.dirpath + '/' + f = self.CreateHtmlDoc(cov_dir.dirpath + '/index.html', title) + else: + title = 'Coverage summary' + f = self.CreateHtmlDoc('index.html', title) + + body = f.body + + # Write header section + if cov_dir.dirpath: + self.AddCaptionForSubdir(body, cov_dir.dirpath) + else: + body.E('h2').Text(title) + + table = body.E('table') + + # Coverage by group + self.AddSectionHeader(table, 'Coverage by Group', 'Group') + + for group in sorted(cov_dir.stats_by_group): + self.AddItem(table, group, cov_dir.stats_by_group[group], None) + + # List subdirs + if cov_dir.subdirs: + self.AddSectionHeader(table, 'Subdirectories', 'Subdirectory') + + for d in sorted(cov_dir.subdirs): + self.AddItem(table, d + '/', cov_dir.subdirs[d].stats_by_group['all'], + None, link=d + '/index.html') + + # List files + if cov_dir.files: + self.AddSectionHeader(table, 'Files in This Directory', 'Filename', + is_file=True) + + for filename in sorted(cov_dir.files): + cov_file = cov_dir.files[filename] + self.AddItem(table, filename, cov_file.stats, cov_file.attrs, + link=filename + '.html') + + body.E('p', e_class='time').Text(self.time_string) + f.Write() + + def WriteRoot(self): + """Writes the files in the output root.""" + # Find ourselves + src_dir = os.path.split(self.WriteRoot.func_code.co_filename)[0] + + # Files to copy into output root + copy_files = [ + 'croc.css', + ] + + # Copy files from our directory into the output directory + for copy_file in copy_files: + print ' Copying %s' % copy_file + shutil.copyfile(os.path.join(src_dir, copy_file), + os.path.join(self.output_root, copy_file)) + + def Write(self): + """Writes HTML output.""" + + print 'Writing HTML to %s...' % self.output_root + + # Loop through the tree and write subdirs, breadth-first + # TODO: switch to depth-first and sort values - makes nicer output? + todo = [self.cov.tree] + while todo: + cov_dir = todo.pop(0) + + # Append subdirs to todo list + todo += cov_dir.subdirs.values() + + # Write this subdir + self.WriteSubdir(cov_dir) + + # Write files in this subdir + for cov_file in cov_dir.files.itervalues(): + self.WriteFile(cov_file) + + # Write files in root directory + self.WriteRoot() + diff --git a/tools/code_coverage/croc_scan.py b/tools/code_coverage/croc_scan.py new file mode 100644 index 0000000..e40b45e --- /dev/null +++ b/tools/code_coverage/croc_scan.py @@ -0,0 +1,191 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Crocodile source scanners.""" + + +import re + + +class Scanner(object): + """Generic source scanner.""" + + def __init__(self): + """Constructor.""" + + self.re_token = re.compile('#') + self.comment_to_eol = ['#'] + self.comment_start = None + self.comment_end = None + + def ScanLines(self, lines): + """Scans the lines for executable statements. + + Args: + lines: Iterator returning source lines. + + Returns: + An array of line numbers which are executable. + """ + exe_lines = [] + lineno = 0 + + in_string = None + in_comment = None + comment_index = None + + for line in lines: + lineno += 1 + in_string_at_start = in_string + + for t in self.re_token.finditer(line): + tokenstr = t.groups()[0] + + if in_comment: + # Inside a multi-line comment, so look for end token + if tokenstr == in_comment: + in_comment = None + # Replace comment with spaces + line = (line[:comment_index] + + ' ' * (t.end(0) - comment_index) + + line[t.end(0):]) + + elif in_string: + # Inside a string, so look for end token + if tokenstr == in_string: + in_string = None + + elif tokenstr in self.comment_to_eol: + # Single-line comment, so truncate line at start of token + line = line[:t.start(0)] + break + + elif tokenstr == self.comment_start: + # Multi-line comment start - end token is comment_end + in_comment = self.comment_end + comment_index = t.start(0) + + else: + # Starting a string - end token is same as start + in_string = tokenstr + + # If still in comment at end of line, remove comment + if in_comment: + line = line[:comment_index] + # Next line, delete from the beginnine + comment_index = 0 + + # If line-sans-comments is not empty, claim it may be executable + if line.strip() or in_string_at_start: + exe_lines.append(lineno) + + # Return executable lines + return exe_lines + + def Scan(self, filename): + """Reads the file and scans its lines. + + Args: + filename: Path to file to scan. + + Returns: + An array of line numbers which are executable. + """ + + # TODO: All manner of error checking + f = None + try: + f = open(filename, 'rt') + return self.ScanLines(f) + finally: + if f: + f.close() + + +class PythonScanner(Scanner): + """Python source scanner.""" + + def __init__(self): + """Constructor.""" + Scanner.__init__(self) + + # TODO: This breaks for strings ending in more than 2 backslashes. Need + # a pattern which counts only an odd number of backslashes, so the last + # one thus escapes the quote. + self.re_token = re.compile(r'(#|\'\'\'|"""|(?