#!/usr/local/bin/python2.3
# (C) Copyright 2006 Olivier Grisel
# Author: Olivier Grisel <olivier.grisel@ensta.org>
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA
# 02111-1307, USA.
#
# $Id$
"""trace2html.py [options]
Utility to generate HTML test coverage reports for python programs
Example usage
-------------
Use trace2html to directly compute the coverage of a test suite by
specifying the module you are interested in::
$ trace2html.py -w my_module --run-command ./my_testrunner.py
$ firefox coverage_dir/index.html
Or you can collect coverage data generated with trace.py::
$ /usr/lib/python2.4/trace.py -mc -C coverage_dir -f counts my_testrunner.py
Write a report in directory 'other_dir' from data collected in 'counts'::
$ trace2html.py -f counts -o other_dir
$ firefox other_dir/index.html
"""
import cgi
import datetime
import linecache
import optparse
import os
import sys
import trace
import urllib
try:
# Extracted resources from the trace2html data even if it's packaged as
# an egg
from pkg_resources import resource_string
TRACE2HTML_CSS = resource_string('trace2htmldata', 'trace2html.css')
SORTABLETABLE_JS = resource_string('trace2htmldata', 'sortabletable.js')
except ImportError:
# distutils backward compatibility
import trace2htmldata
resources = trace2htmldata.__path__[0]
TRACE2HTML_CSS = file(os.path.join(resources, 'trace2html.css')).read()
SORTABLETABLE_JS = file(os.path.join(resources, 'sortabletable.js')).read()
#
# First some HTML templates
#
HTML_PAGE = """\
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title>%(title)s</title>
<link title="Style" type="text/css" rel="stylesheet" href="%(css_file)s"/>
<script type="text/javascript" src="sortabletable.js"></script>
</head>
<body>
<h1>%(title)s</h1>
%(body)s
<p class="footer">
Report generated by <a href="http://cheeseshop.python.org/pypi/trace2html"
>trace2html</a> on %(date)s.
</p>
</body>
</html>
"""
SUMMARY = """\
<table id="summary" class="summary">
<thead>
<tr><td>Module</td><td>Coverage %%</td></tr>
</thead>
<tbody>
%(summary_lines)s
</tbody>
</table>
<script type="text/javascript">
var packageTable = new SortableTable(document.getElementById("summary"),
["String", "Percentage"]);
packageTable.sort(0);
</script>
"""
SUMMARY_LINE = """\
<tr class=%(row_class)s>
<td class="moduleName">
<a href="%(module_link)s">%(modulename)s</a></td>
<td class="percent">
<div class="coveragePercent">
%(percent)d%%
</div>
<div class="coverageBar">
<div class="inCoverageBar" style="width: %(percent)d%%">
</div>
</div>
</td>
</tr>
"""
ANNOTATED_SOURCE_FILE = """\
%(summary)s
<table class="srcCode">
<tbody>
%(src_lines)s
</tbody>
</table>
"""
ANNOTATED_SOURCE_LINE = """\
<tr class="%(coverage_class)s">
<td class="nbLine">%(line_number)s</td>
<td class="nbHits">%(line_hits)s</td>
<td class="src">%(src_line)s</td>
</tr>
"""
#
# Command line options parsing
#
def parse_args(args):
"""Use optparse to extract options from the argv list
>>> args = '--coverage-file=/tmp/some_file -f another_file'.split()
>>> options, _ = parse_args(args)
>>> options.coverage_files
['/tmp/some_file', 'another_file']
>>> options.report_dir
'coverage_dir'
>>> args = '-f another_file -o /tmp/report'.split()
>>> options, _ = parse_args(args)
>>> options.coverage_files
['another_file']
>>> options.report_dir
'/tmp/report'
"""
parser = optparse.OptionParser(usage=__doc__)
parser.add_option('-f', '--coverage-file',
action='append',
dest='coverage_files',
default=[],
help="Use the content of a trace file")
parser.add_option('-o', '--output-dir',
action='store',
dest='report_dir',
default='coverage_dir',
help="Directory to store the generated HTML report. "
"Defaults to 'coverage_dir'")
# TODO: use it!
parser.add_option('-s', '--with-css-stylesheet',
action='store',
dest='css',
default=None,
help="Use an alternative CSS stylesheet")
parser.add_option('-t', '--self-test',
action='store_true',
dest='selftest',
default=False,
help="Run the tests for trace2html")
parser.add_option('-v', '--verbose',
action='count',
default=0,
help="Set verbose mode on (cumulative)")
parser.add_option('-r', '--run-command',
action='store_true',
dest='run_command',
default=False,
help="Collect coverage data by running the given "
"python script with all trailing arguments")
parser.add_option('-b', '--blacklist-module',
action='append',
dest='blacklist_mods',
default=[],
help="Add a module to the black list")
parser.add_option('-B', '--blacklist-dir',
action='append',
dest='blacklist_dirs',
default=[],
help="Add a directory to the black list")
parser.add_option('-w', '--whitelist-module',
action='append',
dest='whitelist_mods',
default=[],
help="Add a module to the white list")
parser.add_option('-W', '--whitelist-dir',
action='append',
dest='whitelist_dirs',
default=[],
help="Add a directory to the white list")
return parser.parse_args(args=args)
#
# Then comes the logic
#
def _strip_init(modulename):
"""Utility function to clean badly guessed module names::
>>> _strip_init('my_package.__init__')
'my_package'
>>> _strip_init('os.path')
'os.path'
"""
if modulename.endswith('.__init__'):
modulename = modulename[:-len('.__init__')]
return modulename
# First a patch for trace.Trace.globaltrace_lt: this will be applied directly
# on a trace.Trace instance if needed to help it deal with packages
if True:
def globaltrace_lt(self, frame, why, arg):
"""Handler for call events.
If the code block being entered is to be ignored, returns `None',
else returns self.localtrace.
"""
if why == 'call':
code = frame.f_code
filename = code.co_filename
if filename:
# XXX: OG - use fullname instead of simple modname to better
# deal with packages
#modulename = modname(filename)
modulename = _strip_init(trace.fullmodname(filename))
if modulename is not None:
ignore_it = self.ignore.names(filename, modulename)
if not ignore_it:
if self.trace:
print (" --- modulename: %s, funcname: %s"
% (modulename, code.co_name))
return self.localtrace
else:
return None
class Handle(object):
"""Implement the same interface as trace.Ignore but do the opposite
The list of modules of dirs given as arguments of the constructor are a
whitelist of modules to be traced. Files that do not belong to one of those
list are ignores (the `names` method will return 1 as for
trace.Ignore.names)::
>>> handle = Handle(modules=['os'])
>>> handle.names(os.__file__, 'os')
0
>>> handle.names(trace.__file__, 'trace')
1
Handle can reuse an existing trace.Ignore instance and delegate the final
choice by delegation to further refine the selection::
>>> ignore = trace.Ignore(modules=['os.path'])
>>> handle2 = Handle(modules=['os'], ignore=ignore)
>>> handle2.names(os.__file__, 'os')
0
>>> handle2.names(os.path.__file__, 'os.path')
1
Some special cases are always the ignored, such as string code::
>>> handle.names('foo', '<string>')
1
>>> handle2.names('foo', '<string>')
1
And builtins::
>>> handle.names(None, '__builtins__')
1
>>> handle2.names(None, '__builtins__')
1
"""
def __init__(self, modules=None, dirs=None, ignore=None):
# internally use a trace.Ignore instance to reuse the existing logics
# but will inverse it in the names implementation
self._handle = trace.Ignore(modules=modules, dirs=dirs)
# update the '<string>' case however
self._handle._ignore['<string>'] = 0
# store the ignore instance (if any) that will be used in the regular
# way to further refine the decision
self._ignore = ignore
def names(self, filename, modulename):
"""Ask the _handle trace.Ignore instance but volontary mis-interpret
it's reply
"""
modulename = _strip_init(modulename)
if self._handle.names(filename, modulename):
# it's probably a submodule except if filename is None
if filename is None:
# must be a built-in, so we must ignore it anyway
return 1
# filename/modulename is a submodule of the whitelist
if self._ignore is not None:
# further refine with the ignore instance
return self._ignore.names(filename, modulename)
else:
# accept it directly
return 0
# else it's not a submodule, thus ignore it
return 1
class CoverageHtmlResults(trace.CoverageResults):
"""Extend the coverage results class to add the ability to compute coverage
summary data and write it down as html reports
"""
_page_pattern = HTML_PAGE
_summary_pattern = SUMMARY
_summary_line_pattern = SUMMARY_LINE
_annotated_src_file_pattern = ANNOTATED_SOURCE_FILE
_annotated_src_line_pattern = ANNOTATED_SOURCE_LINE
_default_css_filename = 'trace2html.css'
_default_js_filename = 'sortabletable.js'
def writeHtmlReport(self, report_dir, css_filename=None):
"""Write the report of the collected data to report_dir after creating
it if not existing
This is highly 'inspirated' by how trace.CoverageResults writes it's
reports.
>>> import tempfile, shutil, os
>>> report_dir = tempfile.mkdtemp()
>>> reporter = CoverageHtmlResults()
>>> reporter.writeHtmlReport(report_dir) # doctest: +ELLIPSIS
'...index.html'
>>> os.listdir(report_dir)
['trace2html.css', 'sortabletable.js', 'index.html']
Cleaning the test directory::
>>> shutil.rmtree(report_dir)
"""
# write the directory
if not os.path.exists(report_dir):
os.mkdir(report_dir)
# write the css file
if css_filename is not None:
css_data = file(css_filename).read()
else:
css_filename = self._default_css_filename
css_data = TRACE2HTML_CSS
file(os.path.join(report_dir, css_filename), 'w').write(css_data)
# write the js file for summary lines sorting
js_filename = self._default_js_filename
js_data = SORTABLETABLE_JS
file(os.path.join(report_dir, js_filename), 'w').write(js_data)
# turn the counts data ("(filename, lineno) = count") into something
# accessible on a per-file basis
per_file_counts = {}
for (filename, lineno), count in self.counts.iteritems():
lines_hit = per_file_counts.setdefault(filename, {})
lines_hit[lineno] = count
# accumulate summary info
sums = {}
for filename, count in per_file_counts.iteritems():
# skip some "files" we don't care about...
if filename == "<string>":
continue
if filename.endswith(".pyc") or filename.endswith(".pyo"):
filename = filename[:-1]
# Get a list of the line numbers which represent executable content
# (returned as a dict for better lookup speed)
lnotab = trace.find_executable_linenos(filename)
source = linecache.getlines(filename)
modulename = trace.fullmodname(filename)
modulename = _strip_init(modulename)
percent = self._writeAnnotatedSourceCodePage(
report_dir, modulename, source, lnotab, count, css_filename)
sums[modulename] = percent
# write the summary
index_filename = os.path.join(report_dir, 'index.html')
self._writePage(index_filename, 'Coverage Report - All Modules',
self._summary(sums), css_filename)
return os.path.abspath(index_filename)
def _writeAnnotatedSourceCodePage(self, report_dir, modulename, lines,
lnotab, lines_hit, css_filename=None):
"""Write an annotated html version of the source code of the module
This is highly inspirated by the CoverageResults.write_results_file
method.
"""
filename = os.path.join(report_dir, modulename + ".html")
# counters for the summary
n_hits, n_lines = 0, 0
annotated_lines = ''
for i, line in enumerate(lines):
lineno = i + 1
if line.strip():
src_line = '<pre>%s</pre>' % cgi.escape(line[:-1])
else:
src_line = ''
params = {
'coverage_class': '',
'line_number': str(lineno),
'line_hits': '',
'src_line': src_line,
}
# do the blank/comment match to try to mark more lines
# (help the reader find stuff that hasn't been covered)
if lineno in lines_hit:
params['line_hits'] = str(lines_hit[lineno])
n_hits += 1
n_lines += 1
elif not trace.rx_blank.match(line):
# lines preceded by no marks weren't hit
# Highlight them if so indicated, unless the line contains
# #pragma: NO COVER
if lineno in lnotab and not trace.PRAGMA_NOCOVER in lines[i]:
n_lines += 1
params['line_hits'] = '0'
params['coverage_class'] = 'uncovered'
annotated_lines += ANNOTATED_SOURCE_LINE % params
if not n_lines:
n_lines, n_hits = 1, 1
percent = int(100 * n_hits / n_lines)
summary = self._summary({modulename: percent})
body = ANNOTATED_SOURCE_FILE % {
'module_path': modulename,
'summary': summary,
'src_lines': annotated_lines,
}
title = "Coverage Report - %s" % modulename
self._writePage(filename, title, body, css_filename)
return percent
def _summary(self, stats):
summary_lines = ''
for i, (modulename, percent) in enumerate(sorted(stats.iteritems())):
summary_lines += SUMMARY_LINE % {
'modulename': cgi.escape(modulename),
'module_link': urllib.quote(modulename + '.html'),
'percent': percent,
'row_class': i % 2 is 0 and 'even' or 'odd',
}
return SUMMARY % {'summary_lines': summary_lines}
def _writePage(self, filename, title, body, css_filename=None):
"""Generate an html page a write it to filename"""
data = {
'title': title,
'body': body,
'css_file': css_filename or self._default_css_filename,
'date': str(datetime.datetime.now()),
}
file(filename, 'w').write(self._page_pattern % data)
def main(original_args): #pragma: NO COVER
"""Write a report according to the collected options"""
# split args between trace2html options and the testrunner options
cmd_args = []
args = original_args[:]
if '--run-command' in sys.argv:
i = args.index('--run-command') + 1
args, cmd_args = original_args[:i], original_args[i:]
if '-r' in args:
i = args.index('-r') + 1
args, cmd_args = args[:i], args[i:] + cmd_args
# parse args that are specific to trace2html
options, remaining_args = parse_args(args[1:])
if remaining_args:
print "unrecognised argument %r, please use --help for instructions" % (
remaining_args[0])
sys.exit(1)
# self testing
if options.selftest:
# returns the number of failures as return code
sys.exit(_test(options.verbose)[0])
if not options.coverage_files and not options.run_command:
# avoid writing a report for nothing
if options.verbose:
print "no data to collect: please use --help for instructions"
sys.exit(0)
reporter = CoverageHtmlResults()
for coverage_file in options.coverage_files:
# collecting coverage data from count files
if options.verbose:
print "collecting coverage data for %s..." % coverage_file
reporter.update(trace.CoverageResults(infile=coverage_file))
if options.run_command and cmd_args:
# collecting coverage data from a live trace
if options.verbose:
print "collecting coverage data for %r..." % ' '.join(cmd_args)
sys.argv = cmd_args
progname = cmd_args[0]
sys.path[0] = os.path.split(progname)[0]
# build a tracer with black and white lists logics
if options.blacklist_dirs or options.blacklist_mods:
bl_dirs = map(os.path.abspath, options.blacklist_dirs)
ignore = trace.Ignore(dirs=bl_dirs,
modules=options.blacklist_mods)
else:
ignore = None
if options.whitelist_dirs or options.whitelist_mods:
wl_dirs = map(os.path.abspath, options.whitelist_dirs)
handle = Handle(dirs=wl_dirs,
modules=options.whitelist_mods,
ignore=ignore)
else:
handle = ignore
t = trace.Trace(count=1, trace=0)
# apply our patch to better handle packages
t.globaltrace = lambda x,y,z: globaltrace_lt(t, x, y, z)
if handle:
# use our black/whitelist system instead of the simple ignore list
# that comes by default
t.ignore = handle
# run the tracer
try:
t.run('execfile(%r)' % (progname,))
except IOError, err:
print >> sys.stderr, "cannot run file %r because: %s" % (
sys.argv[0], err)
sys.exit(1)
except SystemExit:
pass
reporter.update(t.results())
# writing the report
try:
report_path = reporter.writeHtmlReport(options.report_dir, options.css)
print "report written to: %s" % report_path
except IOError, err:
print >> sys.stderr, "could not write the report, cause: %s" % err
sys.exit(1)
#
# selftest support
#
def _test(verbose=0):
import doctest
return doctest.testmod(verbose=verbose)
def test_suite():
"""setuptools testrunner integration"""
import trace2html, doctest
return doctest.DocTestSuite(trace2html)
# Run away!
if __name__ == "__main__": #pragma: NO COVER
main(sys.argv)
syntax highlighted by Code2HTML, v. 0.9.1