html.py 13 KB

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