4 """HTML reporting for coverage.py.""" |
4 """HTML reporting for coverage.py.""" |
5 |
5 |
6 import datetime |
6 import datetime |
7 import json |
7 import json |
8 import os |
8 import os |
9 import re |
|
10 import shutil |
9 import shutil |
11 |
10 |
12 import coverage |
11 import coverage |
13 from coverage import env |
12 from coverage import env |
14 from coverage.backward import iitems |
13 from coverage.backward import iitems |
15 from coverage.files import flat_rootname |
14 from coverage.files import flat_rootname |
16 from coverage.misc import CoverageException, Hasher |
15 from coverage.misc import CoverageException, Hasher, isolate_module |
17 from coverage.report import Reporter |
16 from coverage.report import Reporter |
18 from coverage.results import Numbers |
17 from coverage.results import Numbers |
19 from coverage.templite import Templite |
18 from coverage.templite import Templite |
|
19 |
|
20 os = isolate_module(os) |
20 |
21 |
21 |
22 |
22 # Static files are looked for in a list of places. |
23 # Static files are looked for in a list of places. |
23 STATIC_PATH = [ |
24 STATIC_PATH = [ |
24 # The place Debian puts system Javascript libraries. |
25 # The place Debian puts system Javascript libraries. |
55 raise CoverageException( |
56 raise CoverageException( |
56 "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried) |
57 "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried) |
57 ) |
58 ) |
58 |
59 |
59 |
60 |
60 def data(fname): |
61 def read_data(fname): |
61 """Return the contents of a data file of ours.""" |
62 """Return the contents of a data file of ours.""" |
62 with open(data_filename(fname)) as data_file: |
63 with open(data_filename(fname)) as data_file: |
63 return data_file.read() |
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')) |
64 |
71 |
65 |
72 |
66 class HtmlReporter(Reporter): |
73 class HtmlReporter(Reporter): |
67 """HTML reporting.""" |
74 """HTML reporting.""" |
68 |
75 |
91 'pair': pair, |
98 'pair': pair, |
92 'title': title, |
99 'title': title, |
93 '__url__': coverage.__url__, |
100 '__url__': coverage.__url__, |
94 '__version__': coverage.__version__, |
101 '__version__': coverage.__version__, |
95 } |
102 } |
96 self.source_tmpl = Templite( |
103 self.source_tmpl = Templite(read_data("pyfile.html"), self.template_globals) |
97 data("pyfile.html"), self.template_globals |
|
98 ) |
|
99 |
104 |
100 self.coverage = cov |
105 self.coverage = cov |
101 |
106 |
102 self.files = [] |
107 self.files = [] |
103 self.has_arcs = self.coverage.data.has_arcs() |
108 self.has_arcs = self.coverage.data.has_arcs() |
155 shutil.copyfile( |
160 shutil.copyfile( |
156 self.config.extra_css, |
161 self.config.extra_css, |
157 os.path.join(self.directory, self.extra_css) |
162 os.path.join(self.directory, self.extra_css) |
158 ) |
163 ) |
159 |
164 |
160 def write_html(self, fname, html): |
|
161 """Write `html` to `fname`, properly encoded.""" |
|
162 with open(fname, "wb") as fout: |
|
163 fout.write(html.encode('ascii', 'xmlcharrefreplace')) |
|
164 |
|
165 def file_hash(self, source, fr): |
165 def file_hash(self, source, fr): |
166 """Compute a hash that changes if the file needs to be re-reported.""" |
166 """Compute a hash that changes if the file needs to be re-reported.""" |
167 m = Hasher() |
167 m = Hasher() |
168 m.update(source) |
168 m.update(source) |
169 self.coverage.data.add_to_hash(fr.filename, m) |
169 self.coverage.data.add_to_hash(fr.filename, m) |
187 # Get the numbers for this file. |
187 # Get the numbers for this file. |
188 nums = analysis.numbers |
188 nums = analysis.numbers |
189 |
189 |
190 if self.has_arcs: |
190 if self.has_arcs: |
191 missing_branch_arcs = analysis.missing_branch_arcs() |
191 missing_branch_arcs = analysis.missing_branch_arcs() |
|
192 arcs_executed = analysis.arcs_executed() |
192 |
193 |
193 # These classes determine which lines are highlighted by default. |
194 # These classes determine which lines are highlighted by default. |
194 c_run = "run hide_run" |
195 c_run = "run hide_run" |
195 c_exc = "exc" |
196 c_exc = "exc" |
196 c_mis = "mis" |
197 c_mis = "mis" |
200 |
201 |
201 for lineno, line in enumerate(fr.source_token_lines(), start=1): |
202 for lineno, line in enumerate(fr.source_token_lines(), start=1): |
202 # Figure out how to mark this line. |
203 # Figure out how to mark this line. |
203 line_class = [] |
204 line_class = [] |
204 annotate_html = "" |
205 annotate_html = "" |
205 annotate_title = "" |
206 annotate_long = "" |
206 if lineno in analysis.statements: |
207 if lineno in analysis.statements: |
207 line_class.append("stm") |
208 line_class.append("stm") |
208 if lineno in analysis.excluded: |
209 if lineno in analysis.excluded: |
209 line_class.append(c_exc) |
210 line_class.append(c_exc) |
210 elif lineno in analysis.missing: |
211 elif lineno in analysis.missing: |
214 shorts = [] |
215 shorts = [] |
215 longs = [] |
216 longs = [] |
216 for b in missing_branch_arcs[lineno]: |
217 for b in missing_branch_arcs[lineno]: |
217 if b < 0: |
218 if b < 0: |
218 shorts.append("exit") |
219 shorts.append("exit") |
219 longs.append("the function exit") |
|
220 else: |
220 else: |
221 shorts.append(b) |
221 shorts.append(b) |
222 longs.append("line %d" % b) |
222 longs.append(fr.missing_arc_description(lineno, b, arcs_executed)) |
223 # 202F is NARROW NO-BREAK SPACE. |
223 # 202F is NARROW NO-BREAK SPACE. |
224 # 219B is RIGHTWARDS ARROW WITH STROKE. |
224 # 219B is RIGHTWARDS ARROW WITH STROKE. |
225 short_fmt = "%s ↛ %s" |
225 short_fmt = "%s ↛ %s" |
226 annotate_html = ", ".join(short_fmt % (lineno, d) for d in shorts) |
226 annotate_html = ", ".join(short_fmt % (lineno, d) for d in shorts) |
227 annotate_html += " [?]" |
227 |
228 |
|
229 annotate_title = "Line %d was executed, but never jumped to " % lineno |
|
230 if len(longs) == 1: |
228 if len(longs) == 1: |
231 annotate_title += longs[0] |
229 annotate_long = longs[0] |
232 elif len(longs) == 2: |
|
233 annotate_title += longs[0] + " or " + longs[1] |
|
234 else: |
230 else: |
235 annotate_title += ", ".join(longs[:-1]) + ", or " + longs[-1] |
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: |
236 elif lineno in analysis.statements: |
237 line_class.append(c_run) |
237 line_class.append(c_run) |
238 |
238 |
239 # Build the HTML for the line. |
239 # Build the HTML for the line. |
240 html = [] |
240 html = [] |
250 lines.append({ |
250 lines.append({ |
251 'html': ''.join(html), |
251 'html': ''.join(html), |
252 'number': lineno, |
252 'number': lineno, |
253 'class': ' '.join(line_class) or "pln", |
253 'class': ' '.join(line_class) or "pln", |
254 'annotate': annotate_html, |
254 'annotate': annotate_html, |
255 'annotate_title': annotate_title, |
255 'annotate_long': annotate_long, |
256 }) |
256 }) |
257 |
257 |
258 # Write the HTML page for this file. |
258 # Write the HTML page for this file. |
259 template_values = { |
259 html = self.source_tmpl.render({ |
260 'c_exc': c_exc, 'c_mis': c_mis, 'c_par': c_par, 'c_run': c_run, |
260 'c_exc': c_exc, |
261 'has_arcs': self.has_arcs, 'extra_css': self.extra_css, |
261 'c_mis': c_mis, |
262 'fr': fr, 'nums': nums, 'lines': lines, |
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, |
263 'time_stamp': self.time_stamp, |
269 'time_stamp': self.time_stamp, |
264 } |
270 }) |
265 html = spaceless(self.source_tmpl.render(template_values)) |
|
266 |
271 |
267 html_filename = rootname + ".html" |
272 html_filename = rootname + ".html" |
268 html_path = os.path.join(self.directory, html_filename) |
273 html_path = os.path.join(self.directory, html_filename) |
269 self.write_html(html_path, html) |
274 write_html(html_path, html) |
270 |
275 |
271 # Save this file's information for the index file. |
276 # Save this file's information for the index file. |
272 index_info = { |
277 index_info = { |
273 'nums': nums, |
278 'nums': nums, |
274 'html_filename': html_filename, |
279 'html_filename': html_filename, |
277 self.files.append(index_info) |
282 self.files.append(index_info) |
278 self.status.set_index_info(rootname, index_info) |
283 self.status.set_index_info(rootname, index_info) |
279 |
284 |
280 def index_file(self): |
285 def index_file(self): |
281 """Write the index.html file for this report.""" |
286 """Write the index.html file for this report.""" |
282 index_tmpl = Templite(data("index.html"), self.template_globals) |
287 index_tmpl = Templite(read_data("index.html"), self.template_globals) |
283 |
288 |
284 self.totals = sum(f['nums'] for f in self.files) |
289 self.totals = sum(f['nums'] for f in self.files) |
285 |
290 |
286 html = index_tmpl.render({ |
291 html = index_tmpl.render({ |
287 'has_arcs': self.has_arcs, |
292 'has_arcs': self.has_arcs, |
289 'files': self.files, |
294 'files': self.files, |
290 'totals': self.totals, |
295 'totals': self.totals, |
291 'time_stamp': self.time_stamp, |
296 'time_stamp': self.time_stamp, |
292 }) |
297 }) |
293 |
298 |
294 self.write_html(os.path.join(self.directory, "index.html"), html) |
299 write_html(os.path.join(self.directory, "index.html"), html) |
295 |
300 |
296 # Write the latest hashes for next time. |
301 # Write the latest hashes for next time. |
297 self.status.write(self.directory) |
302 self.status.write(self.directory) |
298 |
303 |
299 |
304 |
414 |
419 |
415 |
420 |
416 # Helpers for templates and generating HTML |
421 # Helpers for templates and generating HTML |
417 |
422 |
418 def escape(t): |
423 def escape(t): |
419 """HTML-escape the text in `t`.""" |
424 """HTML-escape the text in `t`. |
420 return ( |
425 |
421 t |
426 This is only suitable for HTML text, not attributes. |
422 # Convert HTML special chars into HTML entities. |
|
423 .replace("&", "&").replace("<", "<").replace(">", ">") |
|
424 .replace("'", "'").replace('"', """) |
|
425 # Convert runs of spaces: "......" -> " . . ." |
|
426 .replace(" ", " ") |
|
427 # To deal with odd-length runs, convert the final pair of spaces |
|
428 # so that "....." -> " . ." |
|
429 .replace(" ", " ") |
|
430 ) |
|
431 |
|
432 |
|
433 def spaceless(html): |
|
434 """Squeeze out some annoying extra space from an HTML string. |
|
435 |
|
436 Nicely-formatted templates mean lots of extra space in the result. |
|
437 Get rid of some. |
|
438 |
427 |
439 """ |
428 """ |
440 html = re.sub(r">\s+<p ", ">\n<p ", html) |
429 # Convert HTML special chars into HTML entities. |
441 return html |
430 return t.replace("&", "&").replace("<", "<") |
442 |
431 |
443 |
432 |
444 def pair(ratio): |
433 def pair(ratio): |
445 """Format a pair of numbers so JavaScript can read them in an attribute.""" |
434 """Format a pair of numbers so JavaScript can read them in an attribute.""" |
446 return "%s %s" % ratio |
435 return "%s %s" % ratio |