|
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 ↛ %s" |
|
240 annotate_html = ", ".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 ' ' |
|
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("&", "&").replace("<", "<") |
|
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 |