|
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
|
2 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
|
3 |
|
4 """HTML reporting for coverage.py.""" |
|
5 |
|
6 import datetime |
|
7 import json |
|
8 import os |
|
9 import re |
|
10 import shutil |
|
11 |
|
12 import coverage |
|
13 from coverage import env |
|
14 from coverage.backward import iitems, SimpleNamespace, format_local_datetime |
|
15 from coverage.data import add_data_to_hash |
|
16 from coverage.files import flat_rootname |
|
17 from coverage.misc import CoverageException, ensure_dir, file_be_gone, Hasher, isolate_module |
|
18 from coverage.report import get_analysis_to_report |
|
19 from coverage.results import Numbers |
|
20 from coverage.templite import Templite |
|
21 |
|
22 os = isolate_module(os) |
|
23 |
|
24 |
|
25 # Static files are looked for in a list of places. |
|
26 STATIC_PATH = [ |
|
27 # The place Debian puts system Javascript libraries. |
|
28 "/usr/share/javascript", |
|
29 |
|
30 # Our htmlfiles directory. |
|
31 os.path.join(os.path.dirname(__file__), "htmlfiles"), |
|
32 ] |
|
33 |
|
34 |
|
35 def data_filename(fname, pkgdir=""): |
|
36 """Return the path to a data file of ours. |
|
37 |
|
38 The file is searched for on `STATIC_PATH`, and the first place it's found, |
|
39 is returned. |
|
40 |
|
41 Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir` |
|
42 is provided, at that sub-directory. |
|
43 |
|
44 """ |
|
45 tried = [] |
|
46 for static_dir in STATIC_PATH: |
|
47 static_filename = os.path.join(static_dir, fname) |
|
48 if os.path.exists(static_filename): |
|
49 return static_filename |
|
50 else: |
|
51 tried.append(static_filename) |
|
52 if pkgdir: |
|
53 static_filename = os.path.join(static_dir, pkgdir, fname) |
|
54 if os.path.exists(static_filename): |
|
55 return static_filename |
|
56 else: |
|
57 tried.append(static_filename) |
|
58 raise CoverageException( |
|
59 "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried) |
|
60 ) |
|
61 |
|
62 |
|
63 def read_data(fname): |
|
64 """Return the contents of a data file of ours.""" |
|
65 with open(data_filename(fname)) as data_file: |
|
66 return data_file.read() |
|
67 |
|
68 |
|
69 def write_html(fname, html): |
|
70 """Write `html` to `fname`, properly encoded.""" |
|
71 html = re.sub(r"(\A\s+)|(\s+$)", "", html, flags=re.MULTILINE) + "\n" |
|
72 with open(fname, "wb") as fout: |
|
73 fout.write(html.encode('ascii', 'xmlcharrefreplace')) |
|
74 |
|
75 |
|
76 class HtmlDataGeneration(object): |
|
77 """Generate structured data to be turned into HTML reports.""" |
|
78 |
|
79 EMPTY = "(empty)" |
|
80 |
|
81 def __init__(self, cov): |
|
82 self.coverage = cov |
|
83 self.config = self.coverage.config |
|
84 data = self.coverage.get_data() |
|
85 self.has_arcs = data.has_arcs() |
|
86 if self.config.show_contexts: |
|
87 if data.measured_contexts() == set([""]): |
|
88 self.coverage._warn("No contexts were measured") |
|
89 data.set_query_contexts(self.config.report_contexts) |
|
90 |
|
91 def data_for_file(self, fr, analysis): |
|
92 """Produce the data needed for one file's report.""" |
|
93 if self.has_arcs: |
|
94 missing_branch_arcs = analysis.missing_branch_arcs() |
|
95 arcs_executed = analysis.arcs_executed() |
|
96 |
|
97 if self.config.show_contexts: |
|
98 contexts_by_lineno = analysis.data.contexts_by_lineno(analysis.filename) |
|
99 |
|
100 lines = [] |
|
101 |
|
102 for lineno, tokens in enumerate(fr.source_token_lines(), start=1): |
|
103 # Figure out how to mark this line. |
|
104 category = None |
|
105 short_annotations = [] |
|
106 long_annotations = [] |
|
107 |
|
108 if lineno in analysis.excluded: |
|
109 category = 'exc' |
|
110 elif lineno in analysis.missing: |
|
111 category = 'mis' |
|
112 elif self.has_arcs and lineno in missing_branch_arcs: |
|
113 category = 'par' |
|
114 for b in missing_branch_arcs[lineno]: |
|
115 if b < 0: |
|
116 short_annotations.append("exit") |
|
117 else: |
|
118 short_annotations.append(b) |
|
119 long_annotations.append(fr.missing_arc_description(lineno, b, arcs_executed)) |
|
120 elif lineno in analysis.statements: |
|
121 category = 'run' |
|
122 |
|
123 contexts = contexts_label = None |
|
124 context_list = None |
|
125 if category and self.config.show_contexts: |
|
126 contexts = sorted(c or self.EMPTY for c in contexts_by_lineno[lineno]) |
|
127 if contexts == [self.EMPTY]: |
|
128 contexts_label = self.EMPTY |
|
129 else: |
|
130 contexts_label = "{} ctx".format(len(contexts)) |
|
131 context_list = contexts |
|
132 |
|
133 lines.append(SimpleNamespace( |
|
134 tokens=tokens, |
|
135 number=lineno, |
|
136 category=category, |
|
137 statement=(lineno in analysis.statements), |
|
138 contexts=contexts, |
|
139 contexts_label=contexts_label, |
|
140 context_list=context_list, |
|
141 short_annotations=short_annotations, |
|
142 long_annotations=long_annotations, |
|
143 )) |
|
144 |
|
145 file_data = SimpleNamespace( |
|
146 relative_filename=fr.relative_filename(), |
|
147 nums=analysis.numbers, |
|
148 lines=lines, |
|
149 ) |
|
150 |
|
151 return file_data |
|
152 |
|
153 |
|
154 class HtmlReporter(object): |
|
155 """HTML reporting.""" |
|
156 |
|
157 # These files will be copied from the htmlfiles directory to the output |
|
158 # directory. |
|
159 STATIC_FILES = [ |
|
160 ("style.css", ""), |
|
161 ("jquery.min.js", "jquery"), |
|
162 ("jquery.ba-throttle-debounce.min.js", "jquery-throttle-debounce"), |
|
163 ("jquery.hotkeys.js", "jquery-hotkeys"), |
|
164 ("jquery.isonscreen.js", "jquery-isonscreen"), |
|
165 ("jquery.tablesorter.min.js", "jquery-tablesorter"), |
|
166 ("coverage_html.js", ""), |
|
167 ("keybd_closed.png", ""), |
|
168 ("keybd_open.png", ""), |
|
169 ("favicon_32.png", ""), |
|
170 ] |
|
171 |
|
172 def __init__(self, cov): |
|
173 self.coverage = cov |
|
174 self.config = self.coverage.config |
|
175 self.directory = self.config.html_dir |
|
176 title = self.config.html_title |
|
177 if env.PY2: |
|
178 title = title.decode("utf8") |
|
179 |
|
180 if self.config.extra_css: |
|
181 self.extra_css = os.path.basename(self.config.extra_css) |
|
182 else: |
|
183 self.extra_css = None |
|
184 |
|
185 self.data = self.coverage.get_data() |
|
186 self.has_arcs = self.data.has_arcs() |
|
187 |
|
188 self.file_summaries = [] |
|
189 self.all_files_nums = [] |
|
190 self.incr = IncrementalChecker(self.directory) |
|
191 self.datagen = HtmlDataGeneration(self.coverage) |
|
192 self.totals = Numbers() |
|
193 |
|
194 self.template_globals = { |
|
195 # Functions available in the templates. |
|
196 'escape': escape, |
|
197 'pair': pair, |
|
198 'len': len, |
|
199 |
|
200 # Constants for this report. |
|
201 '__url__': coverage.__url__, |
|
202 '__version__': coverage.__version__, |
|
203 'title': title, |
|
204 'time_stamp': format_local_datetime(datetime.datetime.now()), |
|
205 'extra_css': self.extra_css, |
|
206 'has_arcs': self.has_arcs, |
|
207 'show_contexts': self.config.show_contexts, |
|
208 |
|
209 # Constants for all reports. |
|
210 # These css classes determine which lines are highlighted by default. |
|
211 'category': { |
|
212 'exc': 'exc show_exc', |
|
213 'mis': 'mis show_mis', |
|
214 'par': 'par run show_par', |
|
215 'run': 'run', |
|
216 } |
|
217 } |
|
218 self.pyfile_html_source = read_data("pyfile.html") |
|
219 self.source_tmpl = Templite(self.pyfile_html_source, self.template_globals) |
|
220 |
|
221 def report(self, morfs): |
|
222 """Generate an HTML report for `morfs`. |
|
223 |
|
224 `morfs` is a list of modules or file names. |
|
225 |
|
226 """ |
|
227 # Read the status data and check that this run used the same |
|
228 # global data as the last run. |
|
229 self.incr.read() |
|
230 self.incr.check_global_data(self.config, self.pyfile_html_source) |
|
231 |
|
232 # Process all the files. |
|
233 for fr, analysis in get_analysis_to_report(self.coverage, morfs): |
|
234 self.html_file(fr, analysis) |
|
235 |
|
236 if not self.all_files_nums: |
|
237 raise CoverageException("No data to report.") |
|
238 |
|
239 self.totals = sum(self.all_files_nums) |
|
240 |
|
241 # Write the index file. |
|
242 self.index_file() |
|
243 |
|
244 self.make_local_static_report_files() |
|
245 return self.totals.n_statements and self.totals.pc_covered |
|
246 |
|
247 def make_local_static_report_files(self): |
|
248 """Make local instances of static files for HTML report.""" |
|
249 # The files we provide must always be copied. |
|
250 for static, pkgdir in self.STATIC_FILES: |
|
251 shutil.copyfile( |
|
252 data_filename(static, pkgdir), |
|
253 os.path.join(self.directory, static) |
|
254 ) |
|
255 |
|
256 # The user may have extra CSS they want copied. |
|
257 if self.extra_css: |
|
258 shutil.copyfile( |
|
259 self.config.extra_css, |
|
260 os.path.join(self.directory, self.extra_css) |
|
261 ) |
|
262 |
|
263 def html_file(self, fr, analysis): |
|
264 """Generate an HTML file for one source file.""" |
|
265 rootname = flat_rootname(fr.relative_filename()) |
|
266 html_filename = rootname + ".html" |
|
267 ensure_dir(self.directory) |
|
268 html_path = os.path.join(self.directory, html_filename) |
|
269 |
|
270 # Get the numbers for this file. |
|
271 nums = analysis.numbers |
|
272 self.all_files_nums.append(nums) |
|
273 |
|
274 if self.config.skip_covered: |
|
275 # Don't report on 100% files. |
|
276 no_missing_lines = (nums.n_missing == 0) |
|
277 no_missing_branches = (nums.n_partial_branches == 0) |
|
278 if no_missing_lines and no_missing_branches: |
|
279 # If there's an existing file, remove it. |
|
280 file_be_gone(html_path) |
|
281 return |
|
282 |
|
283 if self.config.skip_empty: |
|
284 # Don't report on empty files. |
|
285 if nums.n_statements == 0: |
|
286 file_be_gone(html_path) |
|
287 return |
|
288 |
|
289 # Find out if the file on disk is already correct. |
|
290 if self.incr.can_skip_file(self.data, fr, rootname): |
|
291 self.file_summaries.append(self.incr.index_info(rootname)) |
|
292 return |
|
293 |
|
294 # Write the HTML page for this file. |
|
295 file_data = self.datagen.data_for_file(fr, analysis) |
|
296 for ldata in file_data.lines: |
|
297 # Build the HTML for the line. |
|
298 html = [] |
|
299 for tok_type, tok_text in ldata.tokens: |
|
300 if tok_type == "ws": |
|
301 html.append(escape(tok_text)) |
|
302 else: |
|
303 tok_html = escape(tok_text) or ' ' |
|
304 html.append( |
|
305 u'<span class="{}">{}</span>'.format(tok_type, tok_html) |
|
306 ) |
|
307 ldata.html = ''.join(html) |
|
308 |
|
309 if ldata.short_annotations: |
|
310 # 202F is NARROW NO-BREAK SPACE. |
|
311 # 219B is RIGHTWARDS ARROW WITH STROKE. |
|
312 ldata.annotate = u", ".join( |
|
313 u"{} ↛ {}".format(ldata.number, d) |
|
314 for d in ldata.short_annotations |
|
315 ) |
|
316 else: |
|
317 ldata.annotate = None |
|
318 |
|
319 if ldata.long_annotations: |
|
320 longs = ldata.long_annotations |
|
321 if len(longs) == 1: |
|
322 ldata.annotate_long = longs[0] |
|
323 else: |
|
324 ldata.annotate_long = u"{:d} missed branches: {}".format( |
|
325 len(longs), |
|
326 u", ".join( |
|
327 u"{:d}) {}".format(num, ann_long) |
|
328 for num, ann_long in enumerate(longs, start=1) |
|
329 ), |
|
330 ) |
|
331 else: |
|
332 ldata.annotate_long = None |
|
333 |
|
334 css_classes = [] |
|
335 if ldata.category: |
|
336 css_classes.append(self.template_globals['category'][ldata.category]) |
|
337 ldata.css_class = ' '.join(css_classes) or "pln" |
|
338 |
|
339 html = self.source_tmpl.render(file_data.__dict__) |
|
340 write_html(html_path, html) |
|
341 |
|
342 # Save this file's information for the index file. |
|
343 index_info = { |
|
344 'nums': nums, |
|
345 'html_filename': html_filename, |
|
346 'relative_filename': fr.relative_filename(), |
|
347 } |
|
348 self.file_summaries.append(index_info) |
|
349 self.incr.set_index_info(rootname, index_info) |
|
350 |
|
351 def index_file(self): |
|
352 """Write the index.html file for this report.""" |
|
353 index_tmpl = Templite(read_data("index.html"), self.template_globals) |
|
354 |
|
355 html = index_tmpl.render({ |
|
356 'files': self.file_summaries, |
|
357 'totals': self.totals, |
|
358 }) |
|
359 |
|
360 write_html(os.path.join(self.directory, "index.html"), html) |
|
361 |
|
362 # Write the latest hashes for next time. |
|
363 self.incr.write() |
|
364 |
|
365 |
|
366 class IncrementalChecker(object): |
|
367 """Logic and data to support incremental reporting.""" |
|
368 |
|
369 STATUS_FILE = "status.json" |
|
370 STATUS_FORMAT = 2 |
|
371 |
|
372 # pylint: disable=wrong-spelling-in-comment,useless-suppression |
|
373 # The data looks like: |
|
374 # |
|
375 # { |
|
376 # "format": 2, |
|
377 # "globals": "540ee119c15d52a68a53fe6f0897346d", |
|
378 # "version": "4.0a1", |
|
379 # "files": { |
|
380 # "cogapp___init__": { |
|
381 # "hash": "e45581a5b48f879f301c0f30bf77a50c", |
|
382 # "index": { |
|
383 # "html_filename": "cogapp___init__.html", |
|
384 # "relative_filename": "cogapp/__init__", |
|
385 # "nums": [ 1, 14, 0, 0, 0, 0, 0 ] |
|
386 # } |
|
387 # }, |
|
388 # ... |
|
389 # "cogapp_whiteutils": { |
|
390 # "hash": "8504bb427fc488c4176809ded0277d51", |
|
391 # "index": { |
|
392 # "html_filename": "cogapp_whiteutils.html", |
|
393 # "relative_filename": "cogapp/whiteutils", |
|
394 # "nums": [ 1, 59, 0, 1, 28, 2, 2 ] |
|
395 # } |
|
396 # } |
|
397 # } |
|
398 # } |
|
399 |
|
400 def __init__(self, directory): |
|
401 self.directory = directory |
|
402 self.reset() |
|
403 |
|
404 def reset(self): |
|
405 """Initialize to empty. Causes all files to be reported.""" |
|
406 self.globals = '' |
|
407 self.files = {} |
|
408 |
|
409 def read(self): |
|
410 """Read the information we stored last time.""" |
|
411 usable = False |
|
412 try: |
|
413 status_file = os.path.join(self.directory, self.STATUS_FILE) |
|
414 with open(status_file) as fstatus: |
|
415 status = json.load(fstatus) |
|
416 except (IOError, ValueError): |
|
417 usable = False |
|
418 else: |
|
419 usable = True |
|
420 if status['format'] != self.STATUS_FORMAT: |
|
421 usable = False |
|
422 elif status['version'] != coverage.__version__: |
|
423 usable = False |
|
424 |
|
425 if usable: |
|
426 self.files = {} |
|
427 for filename, fileinfo in iitems(status['files']): |
|
428 fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) |
|
429 self.files[filename] = fileinfo |
|
430 self.globals = status['globals'] |
|
431 else: |
|
432 self.reset() |
|
433 |
|
434 def write(self): |
|
435 """Write the current status.""" |
|
436 status_file = os.path.join(self.directory, self.STATUS_FILE) |
|
437 files = {} |
|
438 for filename, fileinfo in iitems(self.files): |
|
439 fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() |
|
440 files[filename] = fileinfo |
|
441 |
|
442 status = { |
|
443 'format': self.STATUS_FORMAT, |
|
444 'version': coverage.__version__, |
|
445 'globals': self.globals, |
|
446 'files': files, |
|
447 } |
|
448 with open(status_file, "w") as fout: |
|
449 json.dump(status, fout, separators=(',', ':')) |
|
450 |
|
451 def check_global_data(self, *data): |
|
452 """Check the global data that can affect incremental reporting.""" |
|
453 m = Hasher() |
|
454 for d in data: |
|
455 m.update(d) |
|
456 these_globals = m.hexdigest() |
|
457 if self.globals != these_globals: |
|
458 self.reset() |
|
459 self.globals = these_globals |
|
460 |
|
461 def can_skip_file(self, data, fr, rootname): |
|
462 """Can we skip reporting this file? |
|
463 |
|
464 `data` is a CoverageData object, `fr` is a `FileReporter`, and |
|
465 `rootname` is the name being used for the file. |
|
466 """ |
|
467 m = Hasher() |
|
468 m.update(fr.source().encode('utf-8')) |
|
469 add_data_to_hash(data, fr.filename, m) |
|
470 this_hash = m.hexdigest() |
|
471 |
|
472 that_hash = self.file_hash(rootname) |
|
473 |
|
474 if this_hash == that_hash: |
|
475 # Nothing has changed to require the file to be reported again. |
|
476 return True |
|
477 else: |
|
478 self.set_file_hash(rootname, this_hash) |
|
479 return False |
|
480 |
|
481 def file_hash(self, fname): |
|
482 """Get the hash of `fname`'s contents.""" |
|
483 return self.files.get(fname, {}).get('hash', '') |
|
484 |
|
485 def set_file_hash(self, fname, val): |
|
486 """Set the hash of `fname`'s contents.""" |
|
487 self.files.setdefault(fname, {})['hash'] = val |
|
488 |
|
489 def index_info(self, fname): |
|
490 """Get the information for index.html for `fname`.""" |
|
491 return self.files.get(fname, {}).get('index', {}) |
|
492 |
|
493 def set_index_info(self, fname, info): |
|
494 """Set the information for index.html for `fname`.""" |
|
495 self.files.setdefault(fname, {})['index'] = info |
|
496 |
|
497 |
|
498 # Helpers for templates and generating HTML |
|
499 |
|
500 def escape(t): |
|
501 """HTML-escape the text in `t`. |
|
502 |
|
503 This is only suitable for HTML text, not attributes. |
|
504 |
|
505 """ |
|
506 # Convert HTML special chars into HTML entities. |
|
507 return t.replace("&", "&").replace("<", "<") |
|
508 |
|
509 |
|
510 def pair(ratio): |
|
511 """Format a pair of numbers so JavaScript can read them in an attribute.""" |
|
512 return "%s %s" % ratio |