#! python # (C) Copyright 2006 Olivier Grisel # Author: Olivier Grisel # 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 = """\ %(title)s

%(title)s

%(body)s """ SUMMARY = """\ %(summary_lines)s
ModuleCoverage %%
""" SUMMARY_LINE = """\ %(modulename)s
%(percent)d%%
""" ANNOTATED_SOURCE_FILE = """\ %(summary)s %(src_lines)s
""" ANNOTATED_SOURCE_LINE = """\ %(line_number)s %(line_hits)s %(src_line)s """ # # 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', '') 1 >>> handle2.names('foo', '') 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 '' case however self._handle._ignore[''] = 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 == "": 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 = '
%s
' % 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)