1 """HTML reporting for Coverage."""
3 import os
, re
, shutil
, sys
6 from coverage
.backward
import pickle
7 from coverage
.misc
import CoverageException
, Hasher
8 from coverage
.phystokens
import source_token_lines
, source_encoding
9 from coverage
.report
import Reporter
10 from coverage
.results
import Numbers
11 from coverage
.templite
import Templite
14 # Static files are looked for in a list of places.
16 # The place Debian puts system Javascript libraries.
17 "/usr/share/javascript",
19 # Our htmlfiles directory.
20 os
.path
.join(os
.path
.dirname(__file__
), "htmlfiles"),
23 def data_filename(fname
, pkgdir
=""):
24 """Return the path to a data file of ours.
26 The file is searched for on `STATIC_PATH`, and the first place it's found,
29 Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir`
30 is provided, at that subdirectory.
33 for static_dir
in STATIC_PATH
:
34 static_filename
= os
.path
.join(static_dir
, fname
)
35 if os
.path
.exists(static_filename
):
36 return static_filename
38 static_filename
= os
.path
.join(static_dir
, pkgdir
, fname
)
39 if os
.path
.exists(static_filename
):
40 return static_filename
41 raise CoverageException("Couldn't find static file %r" % fname
)
45 """Return the contents of a data file of ours."""
46 data_file
= open(data_filename(fname
))
48 return data_file
.read()
53 class HtmlReporter(Reporter
):
56 # These files will be copied from the htmlfiles dir to the output dir.
59 ("jquery.min.js", "jquery"),
60 ("jquery.hotkeys.js", "jquery-hotkeys"),
61 ("jquery.isonscreen.js", "jquery-isonscreen"),
62 ("jquery.tablesorter.min.js", "jquery-tablesorter"),
63 ("coverage_html.js", ""),
64 ("keybd_closed.png", ""),
65 ("keybd_open.png", ""),
68 def __init__(self
, cov
, config
):
69 super(HtmlReporter
, self
).__init
__(cov
, config
)
71 self
.template_globals
= {
73 'title': self
.config
.html_title
,
74 '__url__': coverage
.__url
__,
75 '__version__': coverage
.__version
__,
77 self
.source_tmpl
= Templite(
78 data("pyfile.html"), self
.template_globals
84 self
.arcs
= self
.coverage
.data
.has_arcs()
85 self
.status
= HtmlStatus()
87 self
.totals
= Numbers()
89 def report(self
, morfs
):
90 """Generate an HTML report for `morfs`.
92 `morfs` is a list of modules or filenames.
95 assert self
.config
.html_dir
, "must give a directory for html reporting"
97 # Read the status data.
98 self
.status
.read(self
.config
.html_dir
)
100 # Check that this run used the same settings as the last run.
102 m
.update(self
.config
)
103 these_settings
= m
.digest()
104 if self
.status
.settings_hash() != these_settings
:
106 self
.status
.set_settings_hash(these_settings
)
108 # The user may have extra CSS they want copied.
109 if self
.config
.extra_css
:
110 self
.extra_css
= os
.path
.basename(self
.config
.extra_css
)
112 # Process all the files.
113 self
.report_files(self
.html_file
, morfs
, self
.config
.html_dir
)
116 raise CoverageException("No data to report.")
118 # Write the index file.
121 self
.make_local_static_report_files()
123 return self
.totals
.pc_covered
125 def make_local_static_report_files(self
):
126 """Make local instances of static files for HTML report."""
127 # The files we provide must always be copied.
128 for static
, pkgdir
in self
.STATIC_FILES
:
130 data_filename(static
, pkgdir
),
131 os
.path
.join(self
.directory
, static
)
134 # The user may have extra CSS they want copied.
137 self
.config
.extra_css
,
138 os
.path
.join(self
.directory
, self
.extra_css
)
141 def write_html(self
, fname
, html
):
142 """Write `html` to `fname`, properly encoded."""
143 fout
= open(fname
, "wb")
145 fout
.write(html
.encode('ascii', 'xmlcharrefreplace'))
149 def file_hash(self
, source
, cu
):
150 """Compute a hash that changes if the file needs to be re-reported."""
153 self
.coverage
.data
.add_to_hash(cu
.filename
, m
)
156 def html_file(self
, cu
, analysis
):
157 """Generate an HTML file for one source file."""
158 source_file
= cu
.source_file()
160 source
= source_file
.read()
164 # Find out if the file on disk is already correct.
165 flat_rootname
= cu
.flat_rootname()
166 this_hash
= self
.file_hash(source
, cu
)
167 that_hash
= self
.status
.file_hash(flat_rootname
)
168 if this_hash
== that_hash
:
169 # Nothing has changed to require the file to be reported again.
170 self
.files
.append(self
.status
.index_info(flat_rootname
))
173 self
.status
.set_file_hash(flat_rootname
, this_hash
)
175 # If need be, determine the encoding of the source file. We use it
176 # later to properly write the HTML.
177 if sys
.version_info
< (3, 0):
178 encoding
= source_encoding(source
)
179 # Some UTF8 files have the dreaded UTF8 BOM. If so, junk it.
180 if encoding
.startswith("utf-8") and source
[:3] == "\xef\xbb\xbf":
184 # Get the numbers for this file.
185 nums
= analysis
.numbers
188 missing_branch_arcs
= analysis
.missing_branch_arcs()
190 # These classes determine which lines are highlighted by default.
191 c_run
= "run hide_run"
194 c_par
= "par " + c_run
198 for lineno
, line
in enumerate(source_token_lines(source
)):
199 lineno
+= 1 # 1-based line numbers.
200 # Figure out how to mark this line.
204 if lineno
in analysis
.statements
:
205 line_class
.append("stm")
206 if lineno
in analysis
.excluded
:
207 line_class
.append(c_exc
)
208 elif lineno
in analysis
.missing
:
209 line_class
.append(c_mis
)
210 elif self
.arcs
and lineno
in missing_branch_arcs
:
211 line_class
.append(c_par
)
213 for b
in missing_branch_arcs
[lineno
]:
215 annlines
.append("exit")
217 annlines
.append(str(b
))
218 annotate_html
= " ".join(annlines
)
219 if len(annlines
) > 1:
220 annotate_title
= "no jumps to these line numbers"
221 elif len(annlines
) == 1:
222 annotate_title
= "no jump to this line number"
223 elif lineno
in analysis
.statements
:
224 line_class
.append(c_run
)
226 # Build the HTML for the line
228 for tok_type
, tok_text
in line
:
230 html
.append(escape(tok_text
))
232 tok_html
= escape(tok_text
) or ' '
234 "<span class='%s'>%s</span>" % (tok_type
, tok_html
)
238 'html': ''.join(html
),
240 'class': ' '.join(line_class
) or "pln",
241 'annotate': annotate_html
,
242 'annotate_title': annotate_title
,
245 # Write the HTML page for this file.
246 html
= spaceless(self
.source_tmpl
.render({
247 'c_exc': c_exc
, 'c_mis': c_mis
, 'c_par': c_par
, 'c_run': c_run
,
248 'arcs': self
.arcs
, 'extra_css': self
.extra_css
,
249 'cu': cu
, 'nums': nums
, 'lines': lines
,
252 if sys
.version_info
< (3, 0):
253 html
= html
.decode(encoding
)
255 html_filename
= flat_rootname
+ ".html"
256 html_path
= os
.path
.join(self
.directory
, html_filename
)
257 self
.write_html(html_path
, html
)
259 # Save this file's information for the index file.
262 'html_filename': html_filename
,
265 self
.files
.append(index_info
)
266 self
.status
.set_index_info(flat_rootname
, index_info
)
268 def index_file(self
):
269 """Write the index.html file for this report."""
270 index_tmpl
= Templite(
271 data("index.html"), self
.template_globals
274 self
.totals
= sum([f
['nums'] for f
in self
.files
])
276 html
= index_tmpl
.render({
278 'extra_css': self
.extra_css
,
280 'totals': self
.totals
,
283 if sys
.version_info
< (3, 0):
284 html
= html
.decode("utf-8")
286 os
.path
.join(self
.directory
, "index.html"),
290 # Write the latest hashes for next time.
291 self
.status
.write(self
.directory
)
294 class HtmlStatus(object):
295 """The status information we keep to support incremental reporting."""
297 STATUS_FILE
= "status.dat"
304 """Initialize to empty."""
308 def read(self
, directory
):
309 """Read the last status in `directory`."""
312 status_file
= os
.path
.join(directory
, self
.STATUS_FILE
)
313 fstatus
= open(status_file
, "rb")
315 status
= pickle
.load(fstatus
)
318 except (IOError, ValueError):
322 if status
['format'] != self
.STATUS_FORMAT
:
324 elif status
['version'] != coverage
.__version
__:
328 self
.files
= status
['files']
329 self
.settings
= status
['settings']
333 def write(self
, directory
):
334 """Write the current status to `directory`."""
335 status_file
= os
.path
.join(directory
, self
.STATUS_FILE
)
337 'format': self
.STATUS_FORMAT
,
338 'version': coverage
.__version
__,
339 'settings': self
.settings
,
342 fout
= open(status_file
, "wb")
344 pickle
.dump(status
, fout
)
348 def settings_hash(self
):
349 """Get the hash of the coverage.py settings."""
352 def set_settings_hash(self
, settings
):
353 """Set the hash of the coverage.py settings."""
354 self
.settings
= settings
356 def file_hash(self
, fname
):
357 """Get the hash of `fname`'s contents."""
358 return self
.files
.get(fname
, {}).get('hash', '')
360 def set_file_hash(self
, fname
, val
):
361 """Set the hash of `fname`'s contents."""
362 self
.files
.setdefault(fname
, {})['hash'] = val
364 def index_info(self
, fname
):
365 """Get the information for index.html for `fname`."""
366 return self
.files
.get(fname
, {}).get('index', {})
368 def set_index_info(self
, fname
, info
):
369 """Set the information for index.html for `fname`."""
370 self
.files
.setdefault(fname
, {})['index'] = info
373 # Helpers for templates and generating HTML
376 """HTML-escape the text in `t`."""
378 # Convert HTML special chars into HTML entities.
379 .replace("&", "&").replace("<", "<").replace(">", ">")
380 .replace("'", "'").replace('"', """)
381 # Convert runs of spaces: "......" -> " . . ."
382 .replace(" ", " ")
383 # To deal with odd-length runs, convert the final pair of spaces
384 # so that "....." -> " . ."
385 .replace(" ", " ")
389 """Squeeze out some annoying extra space from an HTML string.
391 Nicely-formatted templates mean lots of extra space in the result.
395 html
= re
.sub(r
">\s+<p ", ">\n<p ", html
)