Annotate.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. # Note: Work in progress
  2. from __future__ import absolute_import
  3. import os
  4. import os.path
  5. import re
  6. import codecs
  7. import textwrap
  8. from datetime import datetime
  9. from functools import partial
  10. from collections import defaultdict
  11. try:
  12. from xml.sax.saxutils import escape as html_escape
  13. except ImportError:
  14. pass
  15. try:
  16. from StringIO import StringIO
  17. except ImportError:
  18. from io import StringIO # does not support writing 'str' in Py2
  19. from . import Version
  20. from .Code import CCodeWriter
  21. from .. import Utils
  22. class AnnotationCCodeWriter(CCodeWriter):
  23. def __init__(self, create_from=None, buffer=None, copy_formatting=True):
  24. CCodeWriter.__init__(self, create_from, buffer, copy_formatting=copy_formatting)
  25. if create_from is None:
  26. self.annotation_buffer = StringIO()
  27. self.last_annotated_pos = None
  28. # annotations[filename][line] -> [(column, AnnotationItem)*]
  29. self.annotations = defaultdict(partial(defaultdict, list))
  30. # code[filename][line] -> str
  31. self.code = defaultdict(partial(defaultdict, str))
  32. # scopes[filename][line] -> set(scopes)
  33. self.scopes = defaultdict(partial(defaultdict, set))
  34. else:
  35. # When creating an insertion point, keep references to the same database
  36. self.annotation_buffer = create_from.annotation_buffer
  37. self.annotations = create_from.annotations
  38. self.code = create_from.code
  39. self.scopes = create_from.scopes
  40. self.last_annotated_pos = create_from.last_annotated_pos
  41. def create_new(self, create_from, buffer, copy_formatting):
  42. return AnnotationCCodeWriter(create_from, buffer, copy_formatting)
  43. def write(self, s):
  44. CCodeWriter.write(self, s)
  45. self.annotation_buffer.write(s)
  46. def mark_pos(self, pos, trace=True):
  47. if pos is not None:
  48. CCodeWriter.mark_pos(self, pos, trace)
  49. if self.funcstate and self.funcstate.scope:
  50. # lambdas and genexprs can result in multiple scopes per line => keep them in a set
  51. self.scopes[pos[0].filename][pos[1]].add(self.funcstate.scope)
  52. if self.last_annotated_pos:
  53. source_desc, line, _ = self.last_annotated_pos
  54. pos_code = self.code[source_desc.filename]
  55. pos_code[line] += self.annotation_buffer.getvalue()
  56. self.annotation_buffer = StringIO()
  57. self.last_annotated_pos = pos
  58. def annotate(self, pos, item):
  59. self.annotations[pos[0].filename][pos[1]].append((pos[2], item))
  60. def _css(self):
  61. """css template will later allow to choose a colormap"""
  62. css = [self._css_template]
  63. for i in range(255):
  64. color = u"FFFF%02x" % int(255/(1+i/10.0))
  65. css.append('.cython.score-%d {background-color: #%s;}' % (i, color))
  66. try:
  67. from pygments.formatters import HtmlFormatter
  68. except ImportError:
  69. pass
  70. else:
  71. css.append(HtmlFormatter().get_style_defs('.cython'))
  72. return '\n'.join(css)
  73. _css_template = textwrap.dedent("""
  74. body.cython { font-family: courier; font-size: 12; }
  75. .cython.tag { }
  76. .cython.line { margin: 0em }
  77. .cython.code { font-size: 9; color: #444444; display: none; margin: 0px 0px 0px 8px; border-left: 8px none; }
  78. .cython.line .run { background-color: #B0FFB0; }
  79. .cython.line .mis { background-color: #FFB0B0; }
  80. .cython.code.run { border-left: 8px solid #B0FFB0; }
  81. .cython.code.mis { border-left: 8px solid #FFB0B0; }
  82. .cython.code .py_c_api { color: red; }
  83. .cython.code .py_macro_api { color: #FF7000; }
  84. .cython.code .pyx_c_api { color: #FF3000; }
  85. .cython.code .pyx_macro_api { color: #FF7000; }
  86. .cython.code .refnanny { color: #FFA000; }
  87. .cython.code .trace { color: #FFA000; }
  88. .cython.code .error_goto { color: #FFA000; }
  89. .cython.code .coerce { color: #008000; border: 1px dotted #008000 }
  90. .cython.code .py_attr { color: #FF0000; font-weight: bold; }
  91. .cython.code .c_attr { color: #0000FF; }
  92. .cython.code .py_call { color: #FF0000; font-weight: bold; }
  93. .cython.code .c_call { color: #0000FF; }
  94. """)
  95. # on-click toggle function to show/hide C source code
  96. _onclick_attr = ' onclick="{0}"'.format((
  97. "(function(s){"
  98. " s.display = s.display === 'block' ? 'none' : 'block'"
  99. "})(this.nextElementSibling.style)"
  100. ).replace(' ', '') # poor dev's JS minification
  101. )
  102. def save_annotation(self, source_filename, target_filename, coverage_xml=None):
  103. with Utils.open_source_file(source_filename) as f:
  104. code = f.read()
  105. generated_code = self.code.get(source_filename, {})
  106. c_file = Utils.decode_filename(os.path.basename(target_filename))
  107. html_filename = os.path.splitext(target_filename)[0] + ".html"
  108. with codecs.open(html_filename, "w", encoding="UTF-8") as out_buffer:
  109. out_buffer.write(self._save_annotation(code, generated_code, c_file, source_filename, coverage_xml))
  110. def _save_annotation_header(self, c_file, source_filename, coverage_timestamp=None):
  111. coverage_info = ''
  112. if coverage_timestamp:
  113. coverage_info = u' with coverage data from {timestamp}'.format(
  114. timestamp=datetime.fromtimestamp(int(coverage_timestamp) // 1000))
  115. outlist = [
  116. textwrap.dedent(u'''\
  117. <!DOCTYPE html>
  118. <!-- Generated by Cython {watermark} -->
  119. <html>
  120. <head>
  121. <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  122. <title>Cython: {filename}</title>
  123. <style type="text/css">
  124. {css}
  125. </style>
  126. </head>
  127. <body class="cython">
  128. <p><span style="border-bottom: solid 1px grey;">Generated by Cython {watermark}</span>{more_info}</p>
  129. <p>
  130. <span style="background-color: #FFFF00">Yellow lines</span> hint at Python interaction.<br />
  131. Click on a line that starts with a "<code>+</code>" to see the C code that Cython generated for it.
  132. </p>
  133. ''').format(css=self._css(), watermark=Version.watermark,
  134. filename=os.path.basename(source_filename) if source_filename else '',
  135. more_info=coverage_info)
  136. ]
  137. if c_file:
  138. outlist.append(u'<p>Raw output: <a href="%s">%s</a></p>\n' % (c_file, c_file))
  139. return outlist
  140. def _save_annotation_footer(self):
  141. return (u'</body></html>\n',)
  142. def _save_annotation(self, code, generated_code, c_file=None, source_filename=None, coverage_xml=None):
  143. """
  144. lines : original cython source code split by lines
  145. generated_code : generated c code keyed by line number in original file
  146. target filename : name of the file in which to store the generated html
  147. c_file : filename in which the c_code has been written
  148. """
  149. if coverage_xml is not None and source_filename:
  150. coverage_timestamp = coverage_xml.get('timestamp', '').strip()
  151. covered_lines = self._get_line_coverage(coverage_xml, source_filename)
  152. else:
  153. coverage_timestamp = covered_lines = None
  154. annotation_items = dict(self.annotations[source_filename])
  155. scopes = dict(self.scopes[source_filename])
  156. outlist = []
  157. outlist.extend(self._save_annotation_header(c_file, source_filename, coverage_timestamp))
  158. outlist.extend(self._save_annotation_body(code, generated_code, annotation_items, scopes, covered_lines))
  159. outlist.extend(self._save_annotation_footer())
  160. return ''.join(outlist)
  161. def _get_line_coverage(self, coverage_xml, source_filename):
  162. coverage_data = None
  163. for entry in coverage_xml.iterfind('.//class'):
  164. if not entry.get('filename'):
  165. continue
  166. if (entry.get('filename') == source_filename or
  167. os.path.abspath(entry.get('filename')) == source_filename):
  168. coverage_data = entry
  169. break
  170. elif source_filename.endswith(entry.get('filename')):
  171. coverage_data = entry # but we might still find a better match...
  172. if coverage_data is None:
  173. return None
  174. return dict(
  175. (int(line.get('number')), int(line.get('hits')))
  176. for line in coverage_data.iterfind('lines/line')
  177. )
  178. def _htmlify_code(self, code):
  179. try:
  180. from pygments import highlight
  181. from pygments.lexers import CythonLexer
  182. from pygments.formatters import HtmlFormatter
  183. except ImportError:
  184. # no Pygments, just escape the code
  185. return html_escape(code)
  186. html_code = highlight(
  187. code, CythonLexer(stripnl=False, stripall=False),
  188. HtmlFormatter(nowrap=True))
  189. return html_code
  190. def _save_annotation_body(self, cython_code, generated_code, annotation_items, scopes, covered_lines=None):
  191. outlist = [u'<div class="cython">']
  192. pos_comment_marker = u'/* \N{HORIZONTAL ELLIPSIS} */\n'
  193. new_calls_map = dict(
  194. (name, 0) for name in
  195. 'refnanny trace py_macro_api py_c_api pyx_macro_api pyx_c_api error_goto'.split()
  196. ).copy
  197. self.mark_pos(None)
  198. def annotate(match):
  199. group_name = match.lastgroup
  200. calls[group_name] += 1
  201. return u"<span class='%s'>%s</span>" % (
  202. group_name, match.group(group_name))
  203. lines = self._htmlify_code(cython_code).splitlines()
  204. lineno_width = len(str(len(lines)))
  205. if not covered_lines:
  206. covered_lines = None
  207. for k, line in enumerate(lines, 1):
  208. try:
  209. c_code = generated_code[k]
  210. except KeyError:
  211. c_code = ''
  212. else:
  213. c_code = _replace_pos_comment(pos_comment_marker, c_code)
  214. if c_code.startswith(pos_comment_marker):
  215. c_code = c_code[len(pos_comment_marker):]
  216. c_code = html_escape(c_code)
  217. calls = new_calls_map()
  218. c_code = _parse_code(annotate, c_code)
  219. score = (5 * calls['py_c_api'] + 2 * calls['pyx_c_api'] +
  220. calls['py_macro_api'] + calls['pyx_macro_api'])
  221. if c_code:
  222. onclick = self._onclick_attr
  223. expandsymbol = '+'
  224. else:
  225. onclick = ''
  226. expandsymbol = '&#xA0;'
  227. covered = ''
  228. if covered_lines is not None and k in covered_lines:
  229. hits = covered_lines[k]
  230. if hits is not None:
  231. covered = 'run' if hits else 'mis'
  232. outlist.append(
  233. u'<pre class="cython line score-{score}"{onclick}>'
  234. # generate line number with expand symbol in front,
  235. # and the right number of digit
  236. u'{expandsymbol}<span class="{covered}">{line:0{lineno_width}d}</span>: {code}</pre>\n'.format(
  237. score=score,
  238. expandsymbol=expandsymbol,
  239. covered=covered,
  240. lineno_width=lineno_width,
  241. line=k,
  242. code=line.rstrip(),
  243. onclick=onclick,
  244. ))
  245. if c_code:
  246. outlist.append(u"<pre class='cython code score-{score} {covered}'>{code}</pre>".format(
  247. score=score, covered=covered, code=c_code))
  248. outlist.append(u"</div>")
  249. return outlist
  250. _parse_code = re.compile((
  251. br'(?P<refnanny>__Pyx_X?(?:GOT|GIVE)REF|__Pyx_RefNanny[A-Za-z]+)|'
  252. br'(?P<trace>__Pyx_Trace[A-Za-z]+)|'
  253. br'(?:'
  254. br'(?P<pyx_macro_api>__Pyx_[A-Z][A-Z_]+)|'
  255. br'(?P<pyx_c_api>(?:__Pyx_[A-Z][a-z_][A-Za-z_]*)|__pyx_convert_[A-Za-z_]*)|'
  256. br'(?P<py_macro_api>Py[A-Z][a-z]+_[A-Z][A-Z_]+)|'
  257. br'(?P<py_c_api>Py[A-Z][a-z]+_[A-Z][a-z][A-Za-z_]*)'
  258. br')(?=\()|' # look-ahead to exclude subsequent '(' from replacement
  259. br'(?P<error_goto>(?:(?<=;) *if [^;]* +)?__PYX_ERR\([^)]+\))'
  260. ).decode('ascii')).sub
  261. _replace_pos_comment = re.compile(
  262. # this matches what Cython generates as code line marker comment
  263. br'^\s*/\*(?:(?:[^*]|\*[^/])*\n)+\s*\*/\s*\n'.decode('ascii'),
  264. re.M
  265. ).sub
  266. class AnnotationItem(object):
  267. def __init__(self, style, text, tag="", size=0):
  268. self.style = style
  269. self.text = text
  270. self.tag = tag
  271. self.size = size
  272. def start(self):
  273. return u"<span class='cython tag %s' title='%s'>%s" % (self.style, self.text, self.tag)
  274. def end(self):
  275. return self.size, u"</span>"