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