blob: fffd9b45b41a92352e2adb3f51e6fd781043e491 [file] [log] [blame]
Tor Norbye3a2425a2013-11-04 10:16:08 -08001"""HTML reporting for Coverage."""
2
3import os, re, shutil
4
5import coverage
6from coverage.backward import pickle
7from coverage.misc import CoverageException, Hasher
8from coverage.phystokens import source_token_lines
9from coverage.report import Reporter
10from 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
16def 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
20def 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
29class 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 # Create the once-per-directory files.
92 for static in self.STATIC_FILES:
93 shutil.copyfile(
94 data_filename("htmlfiles/" + static),
95 os.path.join(self.directory, static)
96 )
97
98 def file_hash(self, source, cu):
99 """Compute a hash that changes if the file needs to be re-reported."""
100 m = Hasher()
101 m.update(source)
102 self.coverage.data.add_to_hash(cu.filename, m)
103 return m.digest()
104
105 def html_file(self, cu, analysis):
106 """Generate an HTML file for one source file."""
107 source_file = cu.source_file()
108 try:
109 source = source_file.read()
110 finally:
111 source_file.close()
112
113 # Find out if the file on disk is already correct.
114 flat_rootname = cu.flat_rootname()
115 this_hash = self.file_hash(source, cu)
116 that_hash = self.status.file_hash(flat_rootname)
117 if this_hash == that_hash:
118 # Nothing has changed to require the file to be reported again.
119 self.files.append(self.status.index_info(flat_rootname))
120 return
121
122 self.status.set_file_hash(flat_rootname, this_hash)
123
124 nums = analysis.numbers
125
126 missing_branch_arcs = analysis.missing_branch_arcs()
127 n_par = 0 # accumulated below.
128 arcs = self.arcs
129
130 # These classes determine which lines are highlighted by default.
131 c_run = "run hide_run"
132 c_exc = "exc"
133 c_mis = "mis"
134 c_par = "par " + c_run
135
136 lines = []
137
138 for lineno, line in enumerate(source_token_lines(source)):
139 lineno += 1 # 1-based line numbers.
140 # Figure out how to mark this line.
141 line_class = []
142 annotate_html = ""
143 annotate_title = ""
144 if lineno in analysis.statements:
145 line_class.append("stm")
146 if lineno in analysis.excluded:
147 line_class.append(c_exc)
148 elif lineno in analysis.missing:
149 line_class.append(c_mis)
150 elif self.arcs and lineno in missing_branch_arcs:
151 line_class.append(c_par)
152 n_par += 1
153 annlines = []
154 for b in missing_branch_arcs[lineno]:
155 if b < 0:
156 annlines.append("exit")
157 else:
158 annlines.append(str(b))
159 annotate_html = "&nbsp;&nbsp; ".join(annlines)
160 if len(annlines) > 1:
161 annotate_title = "no jumps to these line numbers"
162 elif len(annlines) == 1:
163 annotate_title = "no jump to this line number"
164 elif lineno in analysis.statements:
165 line_class.append(c_run)
166
167 # Build the HTML for the line
168 html = []
169 for tok_type, tok_text in line:
170 if tok_type == "ws":
171 html.append(escape(tok_text))
172 else:
173 tok_html = escape(tok_text) or '&nbsp;'
174 html.append(
175 "<span class='%s'>%s</span>" % (tok_type, tok_html)
176 )
177
178 lines.append({
179 'html': ''.join(html),
180 'number': lineno,
181 'class': ' '.join(line_class) or "pln",
182 'annotate': annotate_html,
183 'annotate_title': annotate_title,
184 })
185
186 # Write the HTML page for this file.
187 html_filename = flat_rootname + ".html"
188 html_path = os.path.join(self.directory, html_filename)
189 html = spaceless(self.source_tmpl.render(locals()))
190 fhtml = open(html_path, 'w')
191 try:
192 fhtml.write(html)
193 finally:
194 fhtml.close()
195
196 # Save this file's information for the index file.
197 index_info = {
198 'nums': nums,
199 'par': n_par,
200 'html_filename': html_filename,
201 'name': cu.name,
202 }
203 self.files.append(index_info)
204 self.status.set_index_info(flat_rootname, index_info)
205
206 def index_file(self):
207 """Write the index.html file for this report."""
208 index_tmpl = Templite(
209 data("htmlfiles/index.html"), self.template_globals
210 )
211
212 files = self.files
213 arcs = self.arcs
214
215 totals = sum([f['nums'] for f in files])
216
217 fhtml = open(os.path.join(self.directory, "index.html"), "w")
218 try:
219 fhtml.write(index_tmpl.render(locals()))
220 finally:
221 fhtml.close()
222
223 # Write the latest hashes for next time.
224 self.status.write(self.directory)
225
226
227class HtmlStatus(object):
228 """The status information we keep to support incremental reporting."""
229
230 STATUS_FILE = "status.dat"
231 STATUS_FORMAT = 1
232
233 def __init__(self):
234 self.reset()
235
236 def reset(self):
237 """Initialize to empty."""
238 self.settings = ''
239 self.files = {}
240
241 def read(self, directory):
242 """Read the last status in `directory`."""
243 usable = False
244 try:
245 status_file = os.path.join(directory, self.STATUS_FILE)
246 status = pickle.load(open(status_file, "rb"))
247 except IOError:
248 usable = False
249 else:
250 usable = True
251 if status['format'] != self.STATUS_FORMAT:
252 usable = False
253 elif status['version'] != coverage.__version__:
254 usable = False
255
256 if usable:
257 self.files = status['files']
258 self.settings = status['settings']
259 else:
260 self.reset()
261
262 def write(self, directory):
263 """Write the current status to `directory`."""
264 status_file = os.path.join(directory, self.STATUS_FILE)
265 status = {
266 'format': self.STATUS_FORMAT,
267 'version': coverage.__version__,
268 'settings': self.settings,
269 'files': self.files,
270 }
271 fout = open(status_file, "wb")
272 try:
273 pickle.dump(status, fout)
274 finally:
275 fout.close()
276
277 def settings_hash(self):
278 """Get the hash of the coverage.py settings."""
279 return self.settings
280
281 def set_settings_hash(self, settings):
282 """Set the hash of the coverage.py settings."""
283 self.settings = settings
284
285 def file_hash(self, fname):
286 """Get the hash of `fname`'s contents."""
287 return self.files.get(fname, {}).get('hash', '')
288
289 def set_file_hash(self, fname, val):
290 """Set the hash of `fname`'s contents."""
291 self.files.setdefault(fname, {})['hash'] = val
292
293 def index_info(self, fname):
294 """Get the information for index.html for `fname`."""
295 return self.files.get(fname, {}).get('index', {})
296
297 def set_index_info(self, fname, info):
298 """Set the information for index.html for `fname`."""
299 self.files.setdefault(fname, {})['index'] = info
300
301
302# Helpers for templates and generating HTML
303
304def escape(t):
305 """HTML-escape the text in `t`."""
306 return (t
307 # Convert HTML special chars into HTML entities.
308 .replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
309 .replace("'", "&#39;").replace('"', "&quot;")
310 # Convert runs of spaces: "......" -> "&nbsp;.&nbsp;.&nbsp;."
311 .replace(" ", "&nbsp; ")
312 # To deal with odd-length runs, convert the final pair of spaces
313 # so that "....." -> "&nbsp;.&nbsp;&nbsp;."
314 .replace(" ", "&nbsp; ")
315 )
316
317def spaceless(html):
318 """Squeeze out some annoying extra space from an HTML string.
319
320 Nicely-formatted templates mean lots of extra space in the result.
321 Get rid of some.
322
323 """
324 html = re.sub(">\s+<p ", ">\n<p ", html)
325 return html