1 """HTML reporting for Coverage.""" 2 3 import os, re, shutil 4 5 import coverage 6 from coverage.backward import pickle, write_encoded 7 from coverage.misc import CoverageException, Hasher 8 from coverage.phystokens import source_token_lines 9 from coverage.report import Reporter 10 from coverage.templite import Templite 11 12 # Disable pylint msg W0612, because a bunch of variables look unused, but 13 # they're accessed in a Templite context via locals(). 14 # pylint: disable=W0612 15 16 def data_filename(fname): 17 """Return the path to a data file of ours.""" 18 return os.path.join(os.path.split(__file__)[0], fname) 19 20 def data(fname): 21 """Return the contents of a data file of ours.""" 22 data_file = open(data_filename(fname)) 23 try: 24 return data_file.read() 25 finally: 26 data_file.close() 27 28 29 class HtmlReporter(Reporter): 30 """HTML reporting.""" 31 32 # These files will be copied from the htmlfiles dir to the output dir. 33 STATIC_FILES = [ 34 "style.css", 35 "jquery-1.4.3.min.js", 36 "jquery.hotkeys.js", 37 "jquery.isonscreen.js", 38 "jquery.tablesorter.min.js", 39 "coverage_html.js", 40 "keybd_closed.png", 41 "keybd_open.png", 42 ] 43 44 def __init__(self, cov, ignore_errors=False): 45 super(HtmlReporter, self).__init__(cov, ignore_errors) 46 self.directory = None 47 self.template_globals = { 48 'escape': escape, 49 '__url__': coverage.__url__, 50 '__version__': coverage.__version__, 51 } 52 self.source_tmpl = Templite( 53 data("htmlfiles/pyfile.html"), self.template_globals 54 ) 55 56 self.coverage = cov 57 58 self.files = [] 59 self.arcs = self.coverage.data.has_arcs() 60 self.status = HtmlStatus() 61 62 def report(self, morfs, config=None): 63 """Generate an HTML report for `morfs`. 64 65 `morfs` is a list of modules or filenames. `config` is a 66 CoverageConfig instance. 67 68 """ 69 assert config.html_dir, "must provide a directory for html reporting" 70 71 # Read the status data. 72 self.status.read(config.html_dir) 73 74 # Check that this run used the same settings as the last run. 75 m = Hasher() 76 m.update(config) 77 these_settings = m.digest() 78 if self.status.settings_hash() != these_settings: 79 self.status.reset() 80 self.status.set_settings_hash(these_settings) 81 82 # Process all the files. 83 self.report_files(self.html_file, morfs, config, config.html_dir) 84 85 if not self.files: 86 raise CoverageException("No data to report.") 87 88 # Write the index file. 89 self.index_file() 90 91 self.make_local_static_report_files() 92 93 def make_local_static_report_files(self): 94 """Make local instances of static files for HTML report.""" 95 for static in self.STATIC_FILES: 96 shutil.copyfile( 97 data_filename("htmlfiles/" + static), 98 os.path.join(self.directory, static) 99 ) 100 101 def write_html(self, fname, html): 102 """Write `html` to `fname`, properly encoded.""" 103 write_encoded(fname, html, 'ascii', 'xmlcharrefreplace') 104 105 def file_hash(self, source, cu): 106 """Compute a hash that changes if the file needs to be re-reported.""" 107 m = Hasher() 108 m.update(source) 109 self.coverage.data.add_to_hash(cu.filename, m) 110 return m.digest() 111 112 def html_file(self, cu, analysis): 113 """Generate an HTML file for one source file.""" 114 source_file = cu.source_file() 115 try: 116 source = source_file.read() 117 finally: 118 source_file.close() 119 120 # Find out if the file on disk is already correct. 121 flat_rootname = cu.flat_rootname() 122 this_hash = self.file_hash(source, cu) 123 that_hash = self.status.file_hash(flat_rootname) 124 if this_hash == that_hash: 125 # Nothing has changed to require the file to be reported again. 126 self.files.append(self.status.index_info(flat_rootname)) 127 return 128 129 self.status.set_file_hash(flat_rootname, this_hash) 130 131 nums = analysis.numbers 132 133 missing_branch_arcs = analysis.missing_branch_arcs() 134 n_par = 0 # accumulated below. 135 arcs = self.arcs 136 137 # These classes determine which lines are highlighted by default. 138 c_run = "run hide_run" 139 c_exc = "exc" 140 c_mis = "mis" 141 c_par = "par " + c_run 142 143 lines = [] 144 145 for lineno, line in enumerate(source_token_lines(source)): 146 lineno += 1 # 1-based line numbers. 147 # Figure out how to mark this line. 148 line_class = [] 149 annotate_html = "" 150 annotate_title = "" 151 if lineno in analysis.statements: 152 line_class.append("stm") 153 if lineno in analysis.excluded: 154 line_class.append(c_exc) 155 elif lineno in analysis.missing: 156 line_class.append(c_mis) 157 elif self.arcs and lineno in missing_branch_arcs: 158 line_class.append(c_par) 159 n_par += 1 160 annlines = [] 161 for b in missing_branch_arcs[lineno]: 162 if b < 0: 163 annlines.append("exit") 164 else: 165 annlines.append(str(b)) 166 annotate_html = " ".join(annlines) 167 if len(annlines) > 1: 168 annotate_title = "no jumps to these line numbers" 169 elif len(annlines) == 1: 170 annotate_title = "no jump to this line number" 171 elif lineno in analysis.statements: 172 line_class.append(c_run) 173 174 # Build the HTML for the line 175 html = [] 176 for tok_type, tok_text in line: 177 if tok_type == "ws": 178 html.append(escape(tok_text)) 179 else: 180 tok_html = escape(tok_text) or ' ' 181 html.append( 182 "<span class='%s'>%s</span>" % (tok_type, tok_html) 183 ) 184 185 lines.append({ 186 'html': ''.join(html), 187 'number': lineno, 188 'class': ' '.join(line_class) or "pln", 189 'annotate': annotate_html, 190 'annotate_title': annotate_title, 191 }) 192 193 # Write the HTML page for this file. 194 html_filename = flat_rootname + ".html" 195 html_path = os.path.join(self.directory, html_filename) 196 197 html = spaceless(self.source_tmpl.render(locals())) 198 self.write_html(html_path, html) 199 200 # Save this file's information for the index file. 201 index_info = { 202 'nums': nums, 203 'par': n_par, 204 'html_filename': html_filename, 205 'name': cu.name, 206 } 207 self.files.append(index_info) 208 self.status.set_index_info(flat_rootname, index_info) 209 210 def index_file(self): 211 """Write the index.html file for this report.""" 212 index_tmpl = Templite( 213 data("htmlfiles/index.html"), self.template_globals 214 ) 215 216 files = self.files 217 arcs = self.arcs 218 219 totals = sum([f['nums'] for f in files]) 220 221 self.write_html( 222 os.path.join(self.directory, "index.html"), 223 index_tmpl.render(locals()) 224 ) 225 226 # Write the latest hashes for next time. 227 self.status.write(self.directory) 228 229 230 class HtmlStatus(object): 231 """The status information we keep to support incremental reporting.""" 232 233 STATUS_FILE = "status.dat" 234 STATUS_FORMAT = 1 235 236 def __init__(self): 237 self.reset() 238 239 def reset(self): 240 """Initialize to empty.""" 241 self.settings = '' 242 self.files = {} 243 244 def read(self, directory): 245 """Read the last status in `directory`.""" 246 usable = False 247 try: 248 status_file = os.path.join(directory, self.STATUS_FILE) 249 status = pickle.load(open(status_file, "rb")) 250 except IOError: 251 usable = False 252 else: 253 usable = True 254 if status['format'] != self.STATUS_FORMAT: 255 usable = False 256 elif status['version'] != coverage.__version__: 257 usable = False 258 259 if usable: 260 self.files = status['files'] 261 self.settings = status['settings'] 262 else: 263 self.reset() 264 265 def write(self, directory): 266 """Write the current status to `directory`.""" 267 status_file = os.path.join(directory, self.STATUS_FILE) 268 status = { 269 'format': self.STATUS_FORMAT, 270 'version': coverage.__version__, 271 'settings': self.settings, 272 'files': self.files, 273 } 274 fout = open(status_file, "wb") 275 try: 276 pickle.dump(status, fout) 277 finally: 278 fout.close() 279 280 def settings_hash(self): 281 """Get the hash of the coverage.py settings.""" 282 return self.settings 283 284 def set_settings_hash(self, settings): 285 """Set the hash of the coverage.py settings.""" 286 self.settings = settings 287 288 def file_hash(self, fname): 289 """Get the hash of `fname`'s contents.""" 290 return self.files.get(fname, {}).get('hash', '') 291 292 def set_file_hash(self, fname, val): 293 """Set the hash of `fname`'s contents.""" 294 self.files.setdefault(fname, {})['hash'] = val 295 296 def index_info(self, fname): 297 """Get the information for index.html for `fname`.""" 298 return self.files.get(fname, {}).get('index', {}) 299 300 def set_index_info(self, fname, info): 301 """Set the information for index.html for `fname`.""" 302 self.files.setdefault(fname, {})['index'] = info 303 304 305 # Helpers for templates and generating HTML 306 307 def escape(t): 308 """HTML-escape the text in `t`.""" 309 return (t 310 # Convert HTML special chars into HTML entities. 311 .replace("&", "&").replace("<", "<").replace(">", ">") 312 .replace("'", "'").replace('"', """) 313 # Convert runs of spaces: "......" -> " . . ." 314 .replace(" ", " ") 315 # To deal with odd-length runs, convert the final pair of spaces 316 # so that "....." -> " . ." 317 .replace(" ", " ") 318 ) 319 320 def spaceless(html): 321 """Squeeze out some annoying extra space from an HTML string. 322 323 Nicely-formatted templates mean lots of extra space in the result. 324 Get rid of some. 325 326 """ 327 html = re.sub(">\s+<p ", ">\n<p ", html) 328 return html 329