1# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt
3
4"""HTML reporting for coverage.py."""
5
6import datetime
7import json
8import os
9import re
10import shutil
11
12import coverage
13from coverage import env
14from coverage.backward import iitems
15from coverage.files import flat_rootname
16from coverage.misc import CoverageException, Hasher, isolate_module
17from coverage.report import Reporter
18from coverage.results import Numbers
19from coverage.templite import Templite
20
21os = isolate_module(os)
22
23
24# Static files are looked for in a list of places.
25STATIC_PATH = [
26    # The place Debian puts system Javascript libraries.
27    "/usr/share/javascript",
28
29    # Our htmlfiles directory.
30    os.path.join(os.path.dirname(__file__), "htmlfiles"),
31]
32
33
34def data_filename(fname, pkgdir=""):
35    """Return the path to a data file of ours.
36
37    The file is searched for on `STATIC_PATH`, and the first place it's found,
38    is returned.
39
40    Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir`
41    is provided, at that sub-directory.
42
43    """
44    tried = []
45    for static_dir in STATIC_PATH:
46        static_filename = os.path.join(static_dir, fname)
47        if os.path.exists(static_filename):
48            return static_filename
49        else:
50            tried.append(static_filename)
51        if pkgdir:
52            static_filename = os.path.join(static_dir, pkgdir, fname)
53            if os.path.exists(static_filename):
54                return static_filename
55            else:
56                tried.append(static_filename)
57    raise CoverageException(
58        "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried)
59    )
60
61
62def data(fname):
63    """Return the contents of a data file of ours."""
64    with open(data_filename(fname)) as data_file:
65        return data_file.read()
66
67
68class HtmlReporter(Reporter):
69    """HTML reporting."""
70
71    # These files will be copied from the htmlfiles directory to the output
72    # directory.
73    STATIC_FILES = [
74        ("style.css", ""),
75        ("jquery.min.js", "jquery"),
76        ("jquery.debounce.min.js", "jquery-debounce"),
77        ("jquery.hotkeys.js", "jquery-hotkeys"),
78        ("jquery.isonscreen.js", "jquery-isonscreen"),
79        ("jquery.tablesorter.min.js", "jquery-tablesorter"),
80        ("coverage_html.js", ""),
81        ("keybd_closed.png", ""),
82        ("keybd_open.png", ""),
83    ]
84
85    def __init__(self, cov, config):
86        super(HtmlReporter, self).__init__(cov, config)
87        self.directory = None
88        title = self.config.html_title
89        if env.PY2:
90            title = title.decode("utf8")
91        self.template_globals = {
92            'escape': escape,
93            'pair': pair,
94            'title': title,
95            '__url__': coverage.__url__,
96            '__version__': coverage.__version__,
97        }
98        self.source_tmpl = Templite(
99            data("pyfile.html"), self.template_globals
100        )
101
102        self.coverage = cov
103
104        self.files = []
105        self.has_arcs = self.coverage.data.has_arcs()
106        self.status = HtmlStatus()
107        self.extra_css = None
108        self.totals = Numbers()
109        self.time_stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
110
111    def report(self, morfs):
112        """Generate an HTML report for `morfs`.
113
114        `morfs` is a list of modules or file names.
115
116        """
117        assert self.config.html_dir, "must give a directory for html reporting"
118
119        # Read the status data.
120        self.status.read(self.config.html_dir)
121
122        # Check that this run used the same settings as the last run.
123        m = Hasher()
124        m.update(self.config)
125        these_settings = m.hexdigest()
126        if self.status.settings_hash() != these_settings:
127            self.status.reset()
128            self.status.set_settings_hash(these_settings)
129
130        # The user may have extra CSS they want copied.
131        if self.config.extra_css:
132            self.extra_css = os.path.basename(self.config.extra_css)
133
134        # Process all the files.
135        self.report_files(self.html_file, morfs, self.config.html_dir)
136
137        if not self.files:
138            raise CoverageException("No data to report.")
139
140        # Write the index file.
141        self.index_file()
142
143        self.make_local_static_report_files()
144        return self.totals.n_statements and self.totals.pc_covered
145
146    def make_local_static_report_files(self):
147        """Make local instances of static files for HTML report."""
148        # The files we provide must always be copied.
149        for static, pkgdir in self.STATIC_FILES:
150            shutil.copyfile(
151                data_filename(static, pkgdir),
152                os.path.join(self.directory, static)
153            )
154
155        # The user may have extra CSS they want copied.
156        if self.extra_css:
157            shutil.copyfile(
158                self.config.extra_css,
159                os.path.join(self.directory, self.extra_css)
160            )
161
162    def write_html(self, fname, html):
163        """Write `html` to `fname`, properly encoded."""
164        with open(fname, "wb") as fout:
165            fout.write(html.encode('ascii', 'xmlcharrefreplace'))
166
167    def file_hash(self, source, fr):
168        """Compute a hash that changes if the file needs to be re-reported."""
169        m = Hasher()
170        m.update(source)
171        self.coverage.data.add_to_hash(fr.filename, m)
172        return m.hexdigest()
173
174    def html_file(self, fr, analysis):
175        """Generate an HTML file for one source file."""
176        source = fr.source()
177
178        # Find out if the file on disk is already correct.
179        rootname = flat_rootname(fr.relative_filename())
180        this_hash = self.file_hash(source.encode('utf-8'), fr)
181        that_hash = self.status.file_hash(rootname)
182        if this_hash == that_hash:
183            # Nothing has changed to require the file to be reported again.
184            self.files.append(self.status.index_info(rootname))
185            return
186
187        self.status.set_file_hash(rootname, this_hash)
188
189        # Get the numbers for this file.
190        nums = analysis.numbers
191
192        if self.has_arcs:
193            missing_branch_arcs = analysis.missing_branch_arcs()
194
195        # These classes determine which lines are highlighted by default.
196        c_run = "run hide_run"
197        c_exc = "exc"
198        c_mis = "mis"
199        c_par = "par " + c_run
200
201        lines = []
202
203        for lineno, line in enumerate(fr.source_token_lines(), start=1):
204            # Figure out how to mark this line.
205            line_class = []
206            annotate_html = ""
207            annotate_title = ""
208            if lineno in analysis.statements:
209                line_class.append("stm")
210            if lineno in analysis.excluded:
211                line_class.append(c_exc)
212            elif lineno in analysis.missing:
213                line_class.append(c_mis)
214            elif self.has_arcs and lineno in missing_branch_arcs:
215                line_class.append(c_par)
216                shorts = []
217                longs = []
218                for b in missing_branch_arcs[lineno]:
219                    if b < 0:
220                        shorts.append("exit")
221                        longs.append("the function exit")
222                    else:
223                        shorts.append(b)
224                        longs.append("line %d" % b)
225                # 202F is NARROW NO-BREAK SPACE.
226                # 219B is RIGHTWARDS ARROW WITH STROKE.
227                short_fmt = "%s&#x202F;&#x219B;&#x202F;%s"
228                annotate_html = ",&nbsp;&nbsp; ".join(short_fmt % (lineno, d) for d in shorts)
229                annotate_html += " [?]"
230
231                annotate_title = "Line %d was executed, but never jumped to " % lineno
232                if len(longs) == 1:
233                    annotate_title += longs[0]
234                elif len(longs) == 2:
235                    annotate_title += longs[0] + " or " + longs[1]
236                else:
237                    annotate_title += ", ".join(longs[:-1]) + ", or " + longs[-1]
238            elif lineno in analysis.statements:
239                line_class.append(c_run)
240
241            # Build the HTML for the line.
242            html = []
243            for tok_type, tok_text in line:
244                if tok_type == "ws":
245                    html.append(escape(tok_text))
246                else:
247                    tok_html = escape(tok_text) or '&nbsp;'
248                    html.append(
249                        '<span class="%s">%s</span>' % (tok_type, tok_html)
250                    )
251
252            lines.append({
253                'html': ''.join(html),
254                'number': lineno,
255                'class': ' '.join(line_class) or "pln",
256                'annotate': annotate_html,
257                'annotate_title': annotate_title,
258            })
259
260        # Write the HTML page for this file.
261        template_values = {
262            'c_exc': c_exc, 'c_mis': c_mis, 'c_par': c_par, 'c_run': c_run,
263            'has_arcs': self.has_arcs, 'extra_css': self.extra_css,
264            'fr': fr, 'nums': nums, 'lines': lines,
265            'time_stamp': self.time_stamp,
266        }
267        html = spaceless(self.source_tmpl.render(template_values))
268
269        html_filename = rootname + ".html"
270        html_path = os.path.join(self.directory, html_filename)
271        self.write_html(html_path, html)
272
273        # Save this file's information for the index file.
274        index_info = {
275            'nums': nums,
276            'html_filename': html_filename,
277            'relative_filename': fr.relative_filename(),
278        }
279        self.files.append(index_info)
280        self.status.set_index_info(rootname, index_info)
281
282    def index_file(self):
283        """Write the index.html file for this report."""
284        index_tmpl = Templite(data("index.html"), self.template_globals)
285
286        self.totals = sum(f['nums'] for f in self.files)
287
288        html = index_tmpl.render({
289            'has_arcs': self.has_arcs,
290            'extra_css': self.extra_css,
291            'files': self.files,
292            'totals': self.totals,
293            'time_stamp': self.time_stamp,
294        })
295
296        self.write_html(os.path.join(self.directory, "index.html"), html)
297
298        # Write the latest hashes for next time.
299        self.status.write(self.directory)
300
301
302class HtmlStatus(object):
303    """The status information we keep to support incremental reporting."""
304
305    STATUS_FILE = "status.json"
306    STATUS_FORMAT = 1
307
308    #           pylint: disable=wrong-spelling-in-comment,useless-suppression
309    #  The data looks like:
310    #
311    #  {
312    #      'format': 1,
313    #      'settings': '540ee119c15d52a68a53fe6f0897346d',
314    #      'version': '4.0a1',
315    #      'files': {
316    #          'cogapp___init__': {
317    #              'hash': 'e45581a5b48f879f301c0f30bf77a50c',
318    #              'index': {
319    #                  'html_filename': 'cogapp___init__.html',
320    #                  'name': 'cogapp/__init__',
321    #                  'nums': <coverage.results.Numbers object at 0x10ab7ed0>,
322    #              }
323    #          },
324    #          ...
325    #          'cogapp_whiteutils': {
326    #              'hash': '8504bb427fc488c4176809ded0277d51',
327    #              'index': {
328    #                  'html_filename': 'cogapp_whiteutils.html',
329    #                  'name': 'cogapp/whiteutils',
330    #                  'nums': <coverage.results.Numbers object at 0x10ab7d90>,
331    #              }
332    #          },
333    #      },
334    #  }
335
336    def __init__(self):
337        self.reset()
338
339    def reset(self):
340        """Initialize to empty."""
341        self.settings = ''
342        self.files = {}
343
344    def read(self, directory):
345        """Read the last status in `directory`."""
346        usable = False
347        try:
348            status_file = os.path.join(directory, self.STATUS_FILE)
349            with open(status_file, "r") as fstatus:
350                status = json.load(fstatus)
351        except (IOError, ValueError):
352            usable = False
353        else:
354            usable = True
355            if status['format'] != self.STATUS_FORMAT:
356                usable = False
357            elif status['version'] != coverage.__version__:
358                usable = False
359
360        if usable:
361            self.files = {}
362            for filename, fileinfo in iitems(status['files']):
363                fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums'])
364                self.files[filename] = fileinfo
365            self.settings = status['settings']
366        else:
367            self.reset()
368
369    def write(self, directory):
370        """Write the current status to `directory`."""
371        status_file = os.path.join(directory, self.STATUS_FILE)
372        files = {}
373        for filename, fileinfo in iitems(self.files):
374            fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args()
375            files[filename] = fileinfo
376
377        status = {
378            'format': self.STATUS_FORMAT,
379            'version': coverage.__version__,
380            'settings': self.settings,
381            'files': files,
382        }
383        with open(status_file, "w") as fout:
384            json.dump(status, fout)
385
386        # Older versions of ShiningPanda look for the old name, status.dat.
387        # Accomodate them if we are running under Jenkins.
388        # https://issues.jenkins-ci.org/browse/JENKINS-28428
389        if "JENKINS_URL" in os.environ:
390            with open(os.path.join(directory, "status.dat"), "w") as dat:
391                dat.write("https://issues.jenkins-ci.org/browse/JENKINS-28428\n")
392
393    def settings_hash(self):
394        """Get the hash of the coverage.py settings."""
395        return self.settings
396
397    def set_settings_hash(self, settings):
398        """Set the hash of the coverage.py settings."""
399        self.settings = settings
400
401    def file_hash(self, fname):
402        """Get the hash of `fname`'s contents."""
403        return self.files.get(fname, {}).get('hash', '')
404
405    def set_file_hash(self, fname, val):
406        """Set the hash of `fname`'s contents."""
407        self.files.setdefault(fname, {})['hash'] = val
408
409    def index_info(self, fname):
410        """Get the information for index.html for `fname`."""
411        return self.files.get(fname, {}).get('index', {})
412
413    def set_index_info(self, fname, info):
414        """Set the information for index.html for `fname`."""
415        self.files.setdefault(fname, {})['index'] = info
416
417
418# Helpers for templates and generating HTML
419
420def escape(t):
421    """HTML-escape the text in `t`."""
422    return (
423        t
424        # Convert HTML special chars into HTML entities.
425        .replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
426        .replace("'", "&#39;").replace('"', "&quot;")
427        # Convert runs of spaces: "......" -> "&nbsp;.&nbsp;.&nbsp;."
428        .replace("  ", "&nbsp; ")
429        # To deal with odd-length runs, convert the final pair of spaces
430        # so that "....." -> "&nbsp;.&nbsp;&nbsp;."
431        .replace("  ", "&nbsp; ")
432    )
433
434
435def spaceless(html):
436    """Squeeze out some annoying extra space from an HTML string.
437
438    Nicely-formatted templates mean lots of extra space in the result.
439    Get rid of some.
440
441    """
442    html = re.sub(r">\s+<p ", ">\n<p ", html)
443    return html
444
445
446def pair(ratio):
447    """Format a pair of numbers so JavaScript can read them in an attribute."""
448    return "%s %s" % ratio
449