Annotate.py 13 KB

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