|
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, 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.debounce.min.js", "jquery-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.has_arcs = self.coverage.data.has_arcs() |
|
109 self.status = HtmlStatus() |
|
110 self.extra_css = None |
|
111 self.totals = Numbers() |
|
112 self.time_stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') |
|
113 |
|
114 def report(self, morfs): |
|
115 """Generate an HTML report for `morfs`. |
|
116 |
|
117 `morfs` is a list of modules or file names. |
|
118 |
|
119 """ |
|
120 assert self.config.html_dir, "must give a directory for html reporting" |
|
121 |
|
122 # Read the status data. |
|
123 self.status.read(self.config.html_dir) |
|
124 |
|
125 # Check that this run used the same settings as the last run. |
|
126 m = Hasher() |
|
127 m.update(self.config) |
|
128 these_settings = m.hexdigest() |
|
129 if self.status.settings_hash() != these_settings: |
|
130 self.status.reset() |
|
131 self.status.set_settings_hash(these_settings) |
|
132 |
|
133 # The user may have extra CSS they want copied. |
|
134 if self.config.extra_css: |
|
135 self.extra_css = os.path.basename(self.config.extra_css) |
|
136 |
|
137 # Process all the files. |
|
138 self.report_files(self.html_file, morfs, self.config.html_dir) |
|
139 |
|
140 if not self.files: |
|
141 raise CoverageException("No data to report.") |
|
142 |
|
143 # Write the index file. |
|
144 self.index_file() |
|
145 |
|
146 self.make_local_static_report_files() |
|
147 return self.totals.n_statements and self.totals.pc_covered |
|
148 |
|
149 def make_local_static_report_files(self): |
|
150 """Make local instances of static files for HTML report.""" |
|
151 # The files we provide must always be copied. |
|
152 for static, pkgdir in self.STATIC_FILES: |
|
153 shutil.copyfile( |
|
154 data_filename(static, pkgdir), |
|
155 os.path.join(self.directory, static) |
|
156 ) |
|
157 |
|
158 # The user may have extra CSS they want copied. |
|
159 if self.extra_css: |
|
160 shutil.copyfile( |
|
161 self.config.extra_css, |
|
162 os.path.join(self.directory, self.extra_css) |
|
163 ) |
|
164 |
|
165 def file_hash(self, source, fr): |
|
166 """Compute a hash that changes if the file needs to be re-reported.""" |
|
167 m = Hasher() |
|
168 m.update(source) |
|
169 self.coverage.data.add_to_hash(fr.filename, m) |
|
170 return m.hexdigest() |
|
171 |
|
172 def html_file(self, fr, analysis): |
|
173 """Generate an HTML file for one source file.""" |
|
174 source = fr.source() |
|
175 |
|
176 # Find out if the file on disk is already correct. |
|
177 rootname = flat_rootname(fr.relative_filename()) |
|
178 this_hash = self.file_hash(source.encode('utf-8'), fr) |
|
179 that_hash = self.status.file_hash(rootname) |
|
180 if this_hash == that_hash: |
|
181 # Nothing has changed to require the file to be reported again. |
|
182 self.files.append(self.status.index_info(rootname)) |
|
183 return |
|
184 |
|
185 self.status.set_file_hash(rootname, this_hash) |
|
186 |
|
187 # Get the numbers for this file. |
|
188 nums = analysis.numbers |
|
189 |
|
190 if self.has_arcs: |
|
191 missing_branch_arcs = analysis.missing_branch_arcs() |
|
192 arcs_executed = analysis.arcs_executed() |
|
193 |
|
194 # These classes determine which lines are highlighted by default. |
|
195 c_run = "run hide_run" |
|
196 c_exc = "exc" |
|
197 c_mis = "mis" |
|
198 c_par = "par " + c_run |
|
199 |
|
200 lines = [] |
|
201 |
|
202 for lineno, line in enumerate(fr.source_token_lines(), start=1): |
|
203 # Figure out how to mark this line. |
|
204 line_class = [] |
|
205 annotate_html = "" |
|
206 annotate_long = "" |
|
207 if lineno in analysis.statements: |
|
208 line_class.append("stm") |
|
209 if lineno in analysis.excluded: |
|
210 line_class.append(c_exc) |
|
211 elif lineno in analysis.missing: |
|
212 line_class.append(c_mis) |
|
213 elif self.has_arcs and lineno in missing_branch_arcs: |
|
214 line_class.append(c_par) |
|
215 shorts = [] |
|
216 longs = [] |
|
217 for b in missing_branch_arcs[lineno]: |
|
218 if b < 0: |
|
219 shorts.append("exit") |
|
220 else: |
|
221 shorts.append(b) |
|
222 longs.append(fr.missing_arc_description(lineno, b, arcs_executed)) |
|
223 # 202F is NARROW NO-BREAK SPACE. |
|
224 # 219B is RIGHTWARDS ARROW WITH STROKE. |
|
225 short_fmt = "%s ↛ %s" |
|
226 annotate_html = ", ".join(short_fmt % (lineno, d) for d in shorts) |
|
227 |
|
228 if len(longs) == 1: |
|
229 annotate_long = longs[0] |
|
230 else: |
|
231 annotate_long = "%d missed branches: %s" % ( |
|
232 len(longs), |
|
233 ", ".join("%d) %s" % (num, ann_long) |
|
234 for num, ann_long in enumerate(longs, start=1)), |
|
235 ) |
|
236 elif lineno in analysis.statements: |
|
237 line_class.append(c_run) |
|
238 |
|
239 # Build the HTML for the line. |
|
240 html = [] |
|
241 for tok_type, tok_text in line: |
|
242 if tok_type == "ws": |
|
243 html.append(escape(tok_text)) |
|
244 else: |
|
245 tok_html = escape(tok_text) or ' ' |
|
246 html.append( |
|
247 '<span class="%s">%s</span>' % (tok_type, tok_html) |
|
248 ) |
|
249 |
|
250 lines.append({ |
|
251 'html': ''.join(html), |
|
252 'number': lineno, |
|
253 'class': ' '.join(line_class) or "pln", |
|
254 'annotate': annotate_html, |
|
255 'annotate_long': annotate_long, |
|
256 }) |
|
257 |
|
258 # Write the HTML page for this file. |
|
259 html = self.source_tmpl.render({ |
|
260 'c_exc': c_exc, |
|
261 'c_mis': c_mis, |
|
262 'c_par': c_par, |
|
263 'c_run': c_run, |
|
264 'has_arcs': self.has_arcs, |
|
265 'extra_css': self.extra_css, |
|
266 'fr': fr, |
|
267 'nums': nums, |
|
268 'lines': lines, |
|
269 'time_stamp': self.time_stamp, |
|
270 }) |
|
271 |
|
272 html_filename = rootname + ".html" |
|
273 html_path = os.path.join(self.directory, html_filename) |
|
274 write_html(html_path, html) |
|
275 |
|
276 # Save this file's information for the index file. |
|
277 index_info = { |
|
278 'nums': nums, |
|
279 'html_filename': html_filename, |
|
280 'relative_filename': fr.relative_filename(), |
|
281 } |
|
282 self.files.append(index_info) |
|
283 self.status.set_index_info(rootname, index_info) |
|
284 |
|
285 def index_file(self): |
|
286 """Write the index.html file for this report.""" |
|
287 index_tmpl = Templite(read_data("index.html"), self.template_globals) |
|
288 |
|
289 self.totals = sum(f['nums'] for f in self.files) |
|
290 |
|
291 html = index_tmpl.render({ |
|
292 'has_arcs': self.has_arcs, |
|
293 'extra_css': self.extra_css, |
|
294 'files': self.files, |
|
295 'totals': self.totals, |
|
296 'time_stamp': self.time_stamp, |
|
297 }) |
|
298 |
|
299 write_html(os.path.join(self.directory, "index.html"), html) |
|
300 |
|
301 # Write the latest hashes for next time. |
|
302 self.status.write(self.directory) |
|
303 |
|
304 |
|
305 class HtmlStatus(object): |
|
306 """The status information we keep to support incremental reporting.""" |
|
307 |
|
308 STATUS_FILE = "status.json" |
|
309 STATUS_FORMAT = 1 |
|
310 |
|
311 # pylint: disable=wrong-spelling-in-comment,useless-suppression |
|
312 # The data looks like: |
|
313 # |
|
314 # { |
|
315 # 'format': 1, |
|
316 # 'settings': '540ee119c15d52a68a53fe6f0897346d', |
|
317 # 'version': '4.0a1', |
|
318 # 'files': { |
|
319 # 'cogapp___init__': { |
|
320 # 'hash': 'e45581a5b48f879f301c0f30bf77a50c', |
|
321 # 'index': { |
|
322 # 'html_filename': 'cogapp___init__.html', |
|
323 # 'name': 'cogapp/__init__', |
|
324 # 'nums': <coverage.results.Numbers object at 0x10ab7ed0>, |
|
325 # } |
|
326 # }, |
|
327 # ... |
|
328 # 'cogapp_whiteutils': { |
|
329 # 'hash': '8504bb427fc488c4176809ded0277d51', |
|
330 # 'index': { |
|
331 # 'html_filename': 'cogapp_whiteutils.html', |
|
332 # 'name': 'cogapp/whiteutils', |
|
333 # 'nums': <coverage.results.Numbers object at 0x10ab7d90>, |
|
334 # } |
|
335 # }, |
|
336 # }, |
|
337 # } |
|
338 |
|
339 def __init__(self): |
|
340 self.reset() |
|
341 |
|
342 def reset(self): |
|
343 """Initialize to empty.""" |
|
344 self.settings = '' |
|
345 self.files = {} |
|
346 |
|
347 def read(self, directory): |
|
348 """Read the last status in `directory`.""" |
|
349 usable = False |
|
350 try: |
|
351 status_file = os.path.join(directory, self.STATUS_FILE) |
|
352 with open(status_file, "r") as fstatus: |
|
353 status = json.load(fstatus) |
|
354 except (IOError, ValueError): |
|
355 usable = False |
|
356 else: |
|
357 usable = True |
|
358 if status['format'] != self.STATUS_FORMAT: |
|
359 usable = False |
|
360 elif status['version'] != coverage.__version__: |
|
361 usable = False |
|
362 |
|
363 if usable: |
|
364 self.files = {} |
|
365 for filename, fileinfo in iitems(status['files']): |
|
366 fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) |
|
367 self.files[filename] = fileinfo |
|
368 self.settings = status['settings'] |
|
369 else: |
|
370 self.reset() |
|
371 |
|
372 def write(self, directory): |
|
373 """Write the current status to `directory`.""" |
|
374 status_file = os.path.join(directory, self.STATUS_FILE) |
|
375 files = {} |
|
376 for filename, fileinfo in iitems(self.files): |
|
377 fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() |
|
378 files[filename] = fileinfo |
|
379 |
|
380 status = { |
|
381 'format': self.STATUS_FORMAT, |
|
382 'version': coverage.__version__, |
|
383 'settings': self.settings, |
|
384 'files': files, |
|
385 } |
|
386 with open(status_file, "w") as fout: |
|
387 json.dump(status, fout) |
|
388 |
|
389 # Older versions of ShiningPanda look for the old name, status.dat. |
|
390 # Accomodate them if we are running under Jenkins. |
|
391 # https://issues.jenkins-ci.org/browse/JENKINS-28428 |
|
392 if "JENKINS_URL" in os.environ: |
|
393 with open(os.path.join(directory, "status.dat"), "w") as dat: |
|
394 dat.write("https://issues.jenkins-ci.org/browse/JENKINS-28428\n") |
|
395 |
|
396 def settings_hash(self): |
|
397 """Get the hash of the coverage.py settings.""" |
|
398 return self.settings |
|
399 |
|
400 def set_settings_hash(self, settings): |
|
401 """Set the hash of the coverage.py settings.""" |
|
402 self.settings = settings |
|
403 |
|
404 def file_hash(self, fname): |
|
405 """Get the hash of `fname`'s contents.""" |
|
406 return self.files.get(fname, {}).get('hash', '') |
|
407 |
|
408 def set_file_hash(self, fname, val): |
|
409 """Set the hash of `fname`'s contents.""" |
|
410 self.files.setdefault(fname, {})['hash'] = val |
|
411 |
|
412 def index_info(self, fname): |
|
413 """Get the information for index.html for `fname`.""" |
|
414 return self.files.get(fname, {}).get('index', {}) |
|
415 |
|
416 def set_index_info(self, fname, info): |
|
417 """Set the information for index.html for `fname`.""" |
|
418 self.files.setdefault(fname, {})['index'] = info |
|
419 |
|
420 |
|
421 # Helpers for templates and generating HTML |
|
422 |
|
423 def escape(t): |
|
424 """HTML-escape the text in `t`. |
|
425 |
|
426 This is only suitable for HTML text, not attributes. |
|
427 |
|
428 """ |
|
429 # Convert HTML special chars into HTML entities. |
|
430 return t.replace("&", "&").replace("<", "<") |
|
431 |
|
432 |
|
433 def pair(ratio): |
|
434 """Format a pair of numbers so JavaScript can read them in an attribute.""" |
|
435 return "%s %s" % ratio |