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