eric6/DebugClients/Python/coverage/html.py

changeset 6942
2602857055c5
parent 6219
d6c795b5ce33
child 7427
362cd1b6f81a
equal deleted inserted replaced
6941:f99d60d6b59b 6942:2602857055c5
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2 # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt
3
4 """HTML reporting for coverage.py."""
5
6 import datetime
7 import json
8 import os
9 import shutil
10
11 import coverage
12 from coverage import env
13 from coverage.backward import iitems
14 from coverage.files import flat_rootname
15 from coverage.misc import CoverageException, file_be_gone, Hasher, isolate_module
16 from coverage.report import Reporter
17 from coverage.results import Numbers
18 from coverage.templite import Templite
19
20 os = isolate_module(os)
21
22
23 # Static files are looked for in a list of places.
24 STATIC_PATH = [
25 # The place Debian puts system Javascript libraries.
26 "/usr/share/javascript",
27
28 # Our htmlfiles directory.
29 os.path.join(os.path.dirname(__file__), "htmlfiles"),
30 ]
31
32
33 def data_filename(fname, pkgdir=""):
34 """Return the path to a data file of ours.
35
36 The file is searched for on `STATIC_PATH`, and the first place it's found,
37 is returned.
38
39 Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir`
40 is provided, at that sub-directory.
41
42 """
43 tried = []
44 for static_dir in STATIC_PATH:
45 static_filename = os.path.join(static_dir, fname)
46 if os.path.exists(static_filename):
47 return static_filename
48 else:
49 tried.append(static_filename)
50 if pkgdir:
51 static_filename = os.path.join(static_dir, pkgdir, fname)
52 if os.path.exists(static_filename):
53 return static_filename
54 else:
55 tried.append(static_filename)
56 raise CoverageException(
57 "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried)
58 )
59
60
61 def read_data(fname):
62 """Return the contents of a data file of ours."""
63 with open(data_filename(fname)) as data_file:
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'))
71
72
73 class HtmlReporter(Reporter):
74 """HTML reporting."""
75
76 # These files will be copied from the htmlfiles directory to the output
77 # directory.
78 STATIC_FILES = [
79 ("style.css", ""),
80 ("jquery.min.js", "jquery"),
81 ("jquery.ba-throttle-debounce.min.js", "jquery-throttle-debounce"),
82 ("jquery.hotkeys.js", "jquery-hotkeys"),
83 ("jquery.isonscreen.js", "jquery-isonscreen"),
84 ("jquery.tablesorter.min.js", "jquery-tablesorter"),
85 ("coverage_html.js", ""),
86 ("keybd_closed.png", ""),
87 ("keybd_open.png", ""),
88 ]
89
90 def __init__(self, cov, config):
91 super(HtmlReporter, self).__init__(cov, config)
92 self.directory = None
93 title = self.config.html_title
94 if env.PY2:
95 title = title.decode("utf8")
96 self.template_globals = {
97 'escape': escape,
98 'pair': pair,
99 'title': title,
100 '__url__': coverage.__url__,
101 '__version__': coverage.__version__,
102 }
103 self.source_tmpl = Templite(read_data("pyfile.html"), self.template_globals)
104
105 self.coverage = cov
106
107 self.files = []
108 self.all_files_nums = []
109 self.has_arcs = self.coverage.data.has_arcs()
110 self.status = HtmlStatus()
111 self.extra_css = None
112 self.totals = Numbers()
113 self.time_stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
114
115 def report(self, morfs):
116 """Generate an HTML report for `morfs`.
117
118 `morfs` is a list of modules or file names.
119
120 """
121 assert self.config.html_dir, "must give a directory for html reporting"
122
123 # Read the status data.
124 self.status.read(self.config.html_dir)
125
126 # Check that this run used the same settings as the last run.
127 m = Hasher()
128 m.update(self.config)
129 these_settings = m.hexdigest()
130 if self.status.settings_hash() != these_settings:
131 self.status.reset()
132 self.status.set_settings_hash(these_settings)
133
134 # The user may have extra CSS they want copied.
135 if self.config.extra_css:
136 self.extra_css = os.path.basename(self.config.extra_css)
137
138 # Process all the files.
139 self.report_files(self.html_file, morfs, self.config.html_dir)
140
141 if not self.all_files_nums:
142 raise CoverageException("No data to report.")
143
144 # Write the index file.
145 self.index_file()
146
147 self.make_local_static_report_files()
148 return self.totals.n_statements and self.totals.pc_covered
149
150 def make_local_static_report_files(self):
151 """Make local instances of static files for HTML report."""
152 # The files we provide must always be copied.
153 for static, pkgdir in self.STATIC_FILES:
154 shutil.copyfile(
155 data_filename(static, pkgdir),
156 os.path.join(self.directory, static)
157 )
158
159 # The user may have extra CSS they want copied.
160 if self.extra_css:
161 shutil.copyfile(
162 self.config.extra_css,
163 os.path.join(self.directory, self.extra_css)
164 )
165
166 def file_hash(self, source, fr):
167 """Compute a hash that changes if the file needs to be re-reported."""
168 m = Hasher()
169 m.update(source)
170 self.coverage.data.add_to_hash(fr.filename, m)
171 return m.hexdigest()
172
173 def html_file(self, fr, analysis):
174 """Generate an HTML file for one source file."""
175 rootname = flat_rootname(fr.relative_filename())
176 html_filename = rootname + ".html"
177 html_path = os.path.join(self.directory, html_filename)
178
179 # Get the numbers for this file.
180 nums = analysis.numbers
181 self.all_files_nums.append(nums)
182
183 if self.config.skip_covered:
184 # Don't report on 100% files.
185 no_missing_lines = (nums.n_missing == 0)
186 no_missing_branches = (nums.n_partial_branches == 0)
187 if no_missing_lines and no_missing_branches:
188 # If there's an existing file, remove it.
189 file_be_gone(html_path)
190 return
191
192 source = fr.source()
193
194 # Find out if the file on disk is already correct.
195 this_hash = self.file_hash(source.encode('utf-8'), fr)
196 that_hash = self.status.file_hash(rootname)
197 if this_hash == that_hash:
198 # Nothing has changed to require the file to be reported again.
199 self.files.append(self.status.index_info(rootname))
200 return
201
202 self.status.set_file_hash(rootname, this_hash)
203
204 if self.has_arcs:
205 missing_branch_arcs = analysis.missing_branch_arcs()
206 arcs_executed = analysis.arcs_executed()
207
208 # These classes determine which lines are highlighted by default.
209 c_run = "run hide_run"
210 c_exc = "exc"
211 c_mis = "mis"
212 c_par = "par " + c_run
213
214 lines = []
215
216 for lineno, line in enumerate(fr.source_token_lines(), start=1):
217 # Figure out how to mark this line.
218 line_class = []
219 annotate_html = ""
220 annotate_long = ""
221 if lineno in analysis.statements:
222 line_class.append("stm")
223 if lineno in analysis.excluded:
224 line_class.append(c_exc)
225 elif lineno in analysis.missing:
226 line_class.append(c_mis)
227 elif self.has_arcs and lineno in missing_branch_arcs:
228 line_class.append(c_par)
229 shorts = []
230 longs = []
231 for b in missing_branch_arcs[lineno]:
232 if b < 0:
233 shorts.append("exit")
234 else:
235 shorts.append(b)
236 longs.append(fr.missing_arc_description(lineno, b, arcs_executed))
237 # 202F is NARROW NO-BREAK SPACE.
238 # 219B is RIGHTWARDS ARROW WITH STROKE.
239 short_fmt = "%s&#x202F;&#x219B;&#x202F;%s"
240 annotate_html = ",&nbsp;&nbsp; ".join(short_fmt % (lineno, d) for d in shorts)
241
242 if len(longs) == 1:
243 annotate_long = longs[0]
244 else:
245 annotate_long = "%d missed branches: %s" % (
246 len(longs),
247 ", ".join("%d) %s" % (num, ann_long)
248 for num, ann_long in enumerate(longs, start=1)),
249 )
250 elif lineno in analysis.statements:
251 line_class.append(c_run)
252
253 # Build the HTML for the line.
254 html = []
255 for tok_type, tok_text in line:
256 if tok_type == "ws":
257 html.append(escape(tok_text))
258 else:
259 tok_html = escape(tok_text) or '&nbsp;'
260 html.append(
261 '<span class="%s">%s</span>' % (tok_type, tok_html)
262 )
263
264 lines.append({
265 'html': ''.join(html),
266 'number': lineno,
267 'class': ' '.join(line_class) or "pln",
268 'annotate': annotate_html,
269 'annotate_long': annotate_long,
270 })
271
272 # Write the HTML page for this file.
273 html = self.source_tmpl.render({
274 'c_exc': c_exc,
275 'c_mis': c_mis,
276 'c_par': c_par,
277 'c_run': c_run,
278 'has_arcs': self.has_arcs,
279 'extra_css': self.extra_css,
280 'fr': fr,
281 'nums': nums,
282 'lines': lines,
283 'time_stamp': self.time_stamp,
284 })
285
286 write_html(html_path, html)
287
288 # Save this file's information for the index file.
289 index_info = {
290 'nums': nums,
291 'html_filename': html_filename,
292 'relative_filename': fr.relative_filename(),
293 }
294 self.files.append(index_info)
295 self.status.set_index_info(rootname, index_info)
296
297 def index_file(self):
298 """Write the index.html file for this report."""
299 index_tmpl = Templite(read_data("index.html"), self.template_globals)
300
301 self.totals = sum(self.all_files_nums)
302
303 html = index_tmpl.render({
304 'has_arcs': self.has_arcs,
305 'extra_css': self.extra_css,
306 'files': self.files,
307 'totals': self.totals,
308 'time_stamp': self.time_stamp,
309 })
310
311 write_html(os.path.join(self.directory, "index.html"), html)
312
313 # Write the latest hashes for next time.
314 self.status.write(self.directory)
315
316
317 class HtmlStatus(object):
318 """The status information we keep to support incremental reporting."""
319
320 STATUS_FILE = "status.json"
321 STATUS_FORMAT = 1
322
323 # pylint: disable=wrong-spelling-in-comment,useless-suppression
324 # The data looks like:
325 #
326 # {
327 # 'format': 1,
328 # 'settings': '540ee119c15d52a68a53fe6f0897346d',
329 # 'version': '4.0a1',
330 # 'files': {
331 # 'cogapp___init__': {
332 # 'hash': 'e45581a5b48f879f301c0f30bf77a50c',
333 # 'index': {
334 # 'html_filename': 'cogapp___init__.html',
335 # 'name': 'cogapp/__init__',
336 # 'nums': <coverage.results.Numbers object at 0x10ab7ed0>,
337 # }
338 # },
339 # ...
340 # 'cogapp_whiteutils': {
341 # 'hash': '8504bb427fc488c4176809ded0277d51',
342 # 'index': {
343 # 'html_filename': 'cogapp_whiteutils.html',
344 # 'name': 'cogapp/whiteutils',
345 # 'nums': <coverage.results.Numbers object at 0x10ab7d90>,
346 # }
347 # },
348 # },
349 # }
350
351 def __init__(self):
352 self.reset()
353
354 def reset(self):
355 """Initialize to empty."""
356 self.settings = ''
357 self.files = {}
358
359 def read(self, directory):
360 """Read the last status in `directory`."""
361 usable = False
362 try:
363 status_file = os.path.join(directory, self.STATUS_FILE)
364 with open(status_file, "r") as fstatus:
365 status = json.load(fstatus)
366 except (IOError, ValueError):
367 usable = False
368 else:
369 usable = True
370 if status['format'] != self.STATUS_FORMAT:
371 usable = False
372 elif status['version'] != coverage.__version__:
373 usable = False
374
375 if usable:
376 self.files = {}
377 for filename, fileinfo in iitems(status['files']):
378 fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums'])
379 self.files[filename] = fileinfo
380 self.settings = status['settings']
381 else:
382 self.reset()
383
384 def write(self, directory):
385 """Write the current status to `directory`."""
386 status_file = os.path.join(directory, self.STATUS_FILE)
387 files = {}
388 for filename, fileinfo in iitems(self.files):
389 fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args()
390 files[filename] = fileinfo
391
392 status = {
393 'format': self.STATUS_FORMAT,
394 'version': coverage.__version__,
395 'settings': self.settings,
396 'files': files,
397 }
398 with open(status_file, "w") as fout:
399 json.dump(status, fout, separators=(',', ':'))
400
401 # Older versions of ShiningPanda look for the old name, status.dat.
402 # Accommodate them if we are running under Jenkins.
403 # https://issues.jenkins-ci.org/browse/JENKINS-28428
404 if "JENKINS_URL" in os.environ:
405 with open(os.path.join(directory, "status.dat"), "w") as dat:
406 dat.write("https://issues.jenkins-ci.org/browse/JENKINS-28428\n")
407
408 def settings_hash(self):
409 """Get the hash of the coverage.py settings."""
410 return self.settings
411
412 def set_settings_hash(self, settings):
413 """Set the hash of the coverage.py settings."""
414 self.settings = settings
415
416 def file_hash(self, fname):
417 """Get the hash of `fname`'s contents."""
418 return self.files.get(fname, {}).get('hash', '')
419
420 def set_file_hash(self, fname, val):
421 """Set the hash of `fname`'s contents."""
422 self.files.setdefault(fname, {})['hash'] = val
423
424 def index_info(self, fname):
425 """Get the information for index.html for `fname`."""
426 return self.files.get(fname, {}).get('index', {})
427
428 def set_index_info(self, fname, info):
429 """Set the information for index.html for `fname`."""
430 self.files.setdefault(fname, {})['index'] = info
431
432
433 # Helpers for templates and generating HTML
434
435 def escape(t):
436 """HTML-escape the text in `t`.
437
438 This is only suitable for HTML text, not attributes.
439
440 """
441 # Convert HTML special chars into HTML entities.
442 return t.replace("&", "&amp;").replace("<", "&lt;")
443
444
445 def pair(ratio):
446 """Format a pair of numbers so JavaScript can read them in an attribute."""
447 return "%s %s" % ratio

eric ide

mercurial