eric7/DebugClients/Python/coverage/html.py

branch
eric7
changeset 8312
800c432b34c8
parent 7975
7d493839a8fc
child 8527
2bd1325d727e
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
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 '&nbsp;'
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",&nbsp;&nbsp; ".join(
313 u"{}&#x202F;&#x219B;&#x202F;{}".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("&", "&amp;").replace("<", "&lt;")
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

eric ide

mercurial