#! python
# (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