1"""HTML reporting for Coverage."""
2
3import os, re, shutil
4
5import coverage
6from coverage.backward import pickle, write_encoded
7from coverage.misc import CoverageException, Hasher
8from coverage.phystokens import source_token_lines
9from coverage.report import Reporter
10from coverage.templite import Templite
11
12# Disable pylint msg W0612, because a bunch of variables look unused, but
13# they're accessed in a Templite context via locals().
14# pylint: disable=W0612
15
16def data_filename(fname):
17    """Return the path to a data file of ours."""
18    return os.path.join(os.path.split(__file__)[0], fname)
19
20def data(fname):
21    """Return the contents of a data file of ours."""
22    data_file = open(data_filename(fname))
23    try:
24        return data_file.read()
25    finally:
26        data_file.close()
27
28
29class HtmlReporter(Reporter):
30    """HTML reporting."""
31
32    # These files will be copied from the htmlfiles dir to the output dir.
33    STATIC_FILES = [
34            "style.css",
35            "jquery-1.4.3.min.js",
36            "jquery.hotkeys.js",
37            "jquery.isonscreen.js",
38            "jquery.tablesorter.min.js",
39            "coverage_html.js",
40            "keybd_closed.png",
41            "keybd_open.png",
42            ]
43
44    def __init__(self, cov, ignore_errors=False):
45        super(HtmlReporter, self).__init__(cov, ignore_errors)
46        self.directory = None
47        self.template_globals = {
48            'escape': escape,
49            '__url__': coverage.__url__,
50            '__version__': coverage.__version__,
51            }
52        self.source_tmpl = Templite(
53            data("htmlfiles/pyfile.html"), self.template_globals
54            )
55
56        self.coverage = cov
57
58        self.files = []
59        self.arcs = self.coverage.data.has_arcs()
60        self.status = HtmlStatus()
61
62    def report(self, morfs, config=None):
63        """Generate an HTML report for `morfs`.
64
65        `morfs` is a list of modules or filenames.  `config` is a
66        CoverageConfig instance.
67
68        """
69        assert config.html_dir, "must provide a directory for html reporting"
70
71        # Read the status data.
72        self.status.read(config.html_dir)
73
74        # Check that this run used the same settings as the last run.
75        m = Hasher()
76        m.update(config)
77        these_settings = m.digest()
78        if self.status.settings_hash() != these_settings:
79            self.status.reset()
80            self.status.set_settings_hash(these_settings)
81
82        # Process all the files.
83        self.report_files(self.html_file, morfs, config, config.html_dir)
84
85        if not self.files:
86            raise CoverageException("No data to report.")
87
88        # Write the index file.
89        self.index_file()
90
91        self.make_local_static_report_files()
92
93    def make_local_static_report_files(self):
94        """Make local instances of static files for HTML report."""
95        for static in self.STATIC_FILES:
96            shutil.copyfile(
97                data_filename("htmlfiles/" + static),
98                os.path.join(self.directory, static)
99                )
100
101    def write_html(self, fname, html):
102        """Write `html` to `fname`, properly encoded."""
103        write_encoded(fname, html, 'ascii', 'xmlcharrefreplace')
104
105    def file_hash(self, source, cu):
106        """Compute a hash that changes if the file needs to be re-reported."""
107        m = Hasher()
108        m.update(source)
109        self.coverage.data.add_to_hash(cu.filename, m)
110        return m.digest()
111
112    def html_file(self, cu, analysis):
113        """Generate an HTML file for one source file."""
114        source_file = cu.source_file()
115        try:
116            source = source_file.read()
117        finally:
118            source_file.close()
119
120        # Find out if the file on disk is already correct.
121        flat_rootname = cu.flat_rootname()
122        this_hash = self.file_hash(source, cu)
123        that_hash = self.status.file_hash(flat_rootname)
124        if this_hash == that_hash:
125            # Nothing has changed to require the file to be reported again.
126            self.files.append(self.status.index_info(flat_rootname))
127            return
128
129        self.status.set_file_hash(flat_rootname, this_hash)
130
131        nums = analysis.numbers
132
133        missing_branch_arcs = analysis.missing_branch_arcs()
134        n_par = 0   # accumulated below.
135        arcs = self.arcs
136
137        # These classes determine which lines are highlighted by default.
138        c_run = "run hide_run"
139        c_exc = "exc"
140        c_mis = "mis"
141        c_par = "par " + c_run
142
143        lines = []
144
145        for lineno, line in enumerate(source_token_lines(source)):
146            lineno += 1     # 1-based line numbers.
147            # Figure out how to mark this line.
148            line_class = []
149            annotate_html = ""
150            annotate_title = ""
151            if lineno in analysis.statements:
152                line_class.append("stm")
153            if lineno in analysis.excluded:
154                line_class.append(c_exc)
155            elif lineno in analysis.missing:
156                line_class.append(c_mis)
157            elif self.arcs and lineno in missing_branch_arcs:
158                line_class.append(c_par)
159                n_par += 1
160                annlines = []
161                for b in missing_branch_arcs[lineno]:
162                    if b < 0:
163                        annlines.append("exit")
164                    else:
165                        annlines.append(str(b))
166                annotate_html = "&nbsp;&nbsp; ".join(annlines)
167                if len(annlines) > 1:
168                    annotate_title = "no jumps to these line numbers"
169                elif len(annlines) == 1:
170                    annotate_title = "no jump to this line number"
171            elif lineno in analysis.statements:
172                line_class.append(c_run)
173
174            # Build the HTML for the line
175            html = []
176            for tok_type, tok_text in line:
177                if tok_type == "ws":
178                    html.append(escape(tok_text))
179                else:
180                    tok_html = escape(tok_text) or '&nbsp;'
181                    html.append(
182                        "<span class='%s'>%s</span>" % (tok_type, tok_html)
183                        )
184
185            lines.append({
186                'html': ''.join(html),
187                'number': lineno,
188                'class': ' '.join(line_class) or "pln",
189                'annotate': annotate_html,
190                'annotate_title': annotate_title,
191            })
192
193        # Write the HTML page for this file.
194        html_filename = flat_rootname + ".html"
195        html_path = os.path.join(self.directory, html_filename)
196
197        html = spaceless(self.source_tmpl.render(locals()))
198        self.write_html(html_path, html)
199
200        # Save this file's information for the index file.
201        index_info = {
202            'nums': nums,
203            'par': n_par,
204            'html_filename': html_filename,
205            'name': cu.name,
206            }
207        self.files.append(index_info)
208        self.status.set_index_info(flat_rootname, index_info)
209
210    def index_file(self):
211        """Write the index.html file for this report."""
212        index_tmpl = Templite(
213            data("htmlfiles/index.html"), self.template_globals
214            )
215
216        files = self.files
217        arcs = self.arcs
218
219        totals = sum([f['nums'] for f in files])
220
221        self.write_html(
222            os.path.join(self.directory, "index.html"),
223            index_tmpl.render(locals())
224            )
225
226        # Write the latest hashes for next time.
227        self.status.write(self.directory)
228
229
230class HtmlStatus(object):
231    """The status information we keep to support incremental reporting."""
232
233    STATUS_FILE = "status.dat"
234    STATUS_FORMAT = 1
235
236    def __init__(self):
237        self.reset()
238
239    def reset(self):
240        """Initialize to empty."""
241        self.settings = ''
242        self.files = {}
243
244    def read(self, directory):
245        """Read the last status in `directory`."""
246        usable = False
247        try:
248            status_file = os.path.join(directory, self.STATUS_FILE)
249            status = pickle.load(open(status_file, "rb"))
250        except IOError:
251            usable = False
252        else:
253            usable = True
254            if status['format'] != self.STATUS_FORMAT:
255                usable = False
256            elif status['version'] != coverage.__version__:
257                usable = False
258
259        if usable:
260            self.files = status['files']
261            self.settings = status['settings']
262        else:
263            self.reset()
264
265    def write(self, directory):
266        """Write the current status to `directory`."""
267        status_file = os.path.join(directory, self.STATUS_FILE)
268        status = {
269            'format': self.STATUS_FORMAT,
270            'version': coverage.__version__,
271            'settings': self.settings,
272            'files': self.files,
273            }
274        fout = open(status_file, "wb")
275        try:
276            pickle.dump(status, fout)
277        finally:
278            fout.close()
279
280    def settings_hash(self):
281        """Get the hash of the coverage.py settings."""
282        return self.settings
283
284    def set_settings_hash(self, settings):
285        """Set the hash of the coverage.py settings."""
286        self.settings = settings
287
288    def file_hash(self, fname):
289        """Get the hash of `fname`'s contents."""
290        return self.files.get(fname, {}).get('hash', '')
291
292    def set_file_hash(self, fname, val):
293        """Set the hash of `fname`'s contents."""
294        self.files.setdefault(fname, {})['hash'] = val
295
296    def index_info(self, fname):
297        """Get the information for index.html for `fname`."""
298        return self.files.get(fname, {}).get('index', {})
299
300    def set_index_info(self, fname, info):
301        """Set the information for index.html for `fname`."""
302        self.files.setdefault(fname, {})['index'] = info
303
304
305# Helpers for templates and generating HTML
306
307def escape(t):
308    """HTML-escape the text in `t`."""
309    return (t
310            # Convert HTML special chars into HTML entities.
311            .replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
312            .replace("'", "&#39;").replace('"', "&quot;")
313            # Convert runs of spaces: "......" -> "&nbsp;.&nbsp;.&nbsp;."
314            .replace("  ", "&nbsp; ")
315            # To deal with odd-length runs, convert the final pair of spaces
316            # so that "....." -> "&nbsp;.&nbsp;&nbsp;."
317            .replace("  ", "&nbsp; ")
318        )
319
320def spaceless(html):
321    """Squeeze out some annoying extra space from an HTML string.
322
323    Nicely-formatted templates mean lots of extra space in the result.
324    Get rid of some.
325
326    """
327    html = re.sub(">\s+<p ", ">\n<p ", html)
328    return html
329