src/eric7/DebugClients/Python/coverage/html.py

branch
eric7-maintenance
changeset 9264
18a7312cfdb3
parent 9209
b99e7fd55fd3
equal deleted inserted replaced
9241:d23e9854aea4 9264:18a7312cfdb3
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 '&nbsp;'
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 = ",&nbsp;&nbsp; ".join(
331 f"{ldata.number}&#x202F;&#x219B;&#x202F;{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("&", "&amp;").replace("<", "&lt;")
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

eric ide

mercurial