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