Coverage.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. """
  2. A Cython plugin for coverage.py
  3. Requires the coverage package at least in version 4.0 (which added the plugin API).
  4. """
  5. from __future__ import absolute_import
  6. import re
  7. import os.path
  8. import sys
  9. from collections import defaultdict
  10. from coverage.plugin import CoveragePlugin, FileTracer, FileReporter # requires coverage.py 4.0+
  11. from coverage.files import canonical_filename
  12. from .Utils import find_root_package_dir, is_package_dir, open_source_file
  13. from . import __version__
  14. def _find_c_source(base_path):
  15. if os.path.exists(base_path + '.c'):
  16. c_file = base_path + '.c'
  17. elif os.path.exists(base_path + '.cpp'):
  18. c_file = base_path + '.cpp'
  19. else:
  20. c_file = None
  21. return c_file
  22. def _find_dep_file_path(main_file, file_path):
  23. abs_path = os.path.abspath(file_path)
  24. if file_path.endswith('.pxi') and not os.path.exists(abs_path):
  25. # include files are looked up relative to the main source file
  26. pxi_file_path = os.path.join(os.path.dirname(main_file), file_path)
  27. if os.path.exists(pxi_file_path):
  28. abs_path = os.path.abspath(pxi_file_path)
  29. # search sys.path for external locations if a valid file hasn't been found
  30. if not os.path.exists(abs_path):
  31. for sys_path in sys.path:
  32. test_path = os.path.realpath(os.path.join(sys_path, file_path))
  33. if os.path.exists(test_path):
  34. return canonical_filename(test_path)
  35. return canonical_filename(abs_path)
  36. class Plugin(CoveragePlugin):
  37. # map from traced file paths to absolute file paths
  38. _file_path_map = None
  39. # map from traced file paths to corresponding C files
  40. _c_files_map = None
  41. # map from parsed C files to their content
  42. _parsed_c_files = None
  43. def sys_info(self):
  44. return [('Cython version', __version__)]
  45. def file_tracer(self, filename):
  46. """
  47. Try to find a C source file for a file path found by the tracer.
  48. """
  49. if filename.startswith('<') or filename.startswith('memory:'):
  50. return None
  51. c_file = py_file = None
  52. filename = canonical_filename(os.path.abspath(filename))
  53. if self._c_files_map and filename in self._c_files_map:
  54. c_file = self._c_files_map[filename][0]
  55. if c_file is None:
  56. c_file, py_file = self._find_source_files(filename)
  57. if not c_file:
  58. return None
  59. # parse all source file paths and lines from C file
  60. # to learn about all relevant source files right away (pyx/pxi/pxd)
  61. # FIXME: this might already be too late if the first executed line
  62. # is not from the main .pyx file but a file with a different
  63. # name than the .c file (which prevents us from finding the
  64. # .c file)
  65. self._parse_lines(c_file, filename)
  66. if self._file_path_map is None:
  67. self._file_path_map = {}
  68. return CythonModuleTracer(filename, py_file, c_file, self._c_files_map, self._file_path_map)
  69. def file_reporter(self, filename):
  70. # TODO: let coverage.py handle .py files itself
  71. #ext = os.path.splitext(filename)[1].lower()
  72. #if ext == '.py':
  73. # from coverage.python import PythonFileReporter
  74. # return PythonFileReporter(filename)
  75. filename = canonical_filename(os.path.abspath(filename))
  76. if self._c_files_map and filename in self._c_files_map:
  77. c_file, rel_file_path, code = self._c_files_map[filename]
  78. else:
  79. c_file, _ = self._find_source_files(filename)
  80. if not c_file:
  81. return None # unknown file
  82. rel_file_path, code = self._parse_lines(c_file, filename)
  83. if code is None:
  84. return None # no source found
  85. return CythonModuleReporter(c_file, filename, rel_file_path, code)
  86. def _find_source_files(self, filename):
  87. basename, ext = os.path.splitext(filename)
  88. ext = ext.lower()
  89. if ext in ('.py', '.pyx', '.pxd', '.c', '.cpp'):
  90. pass
  91. elif ext == '.pyd':
  92. # Windows extension module
  93. platform_suffix = re.search(r'[.]cp[0-9]+-win[_a-z0-9]*$', basename, re.I)
  94. if platform_suffix:
  95. basename = basename[:platform_suffix.start()]
  96. elif ext == '.so':
  97. # Linux/Unix/Mac extension module
  98. platform_suffix = re.search(r'[.](?:cpython|pypy)-[0-9]+[-_a-z0-9]*$', basename, re.I)
  99. if platform_suffix:
  100. basename = basename[:platform_suffix.start()]
  101. elif ext == '.pxi':
  102. # if we get here, it means that the first traced line of a Cython module was
  103. # not in the main module but in an include file, so try a little harder to
  104. # find the main source file
  105. self._find_c_source_files(os.path.dirname(filename), filename)
  106. if filename in self._c_files_map:
  107. return self._c_files_map[filename][0], None
  108. else:
  109. # none of our business
  110. return None, None
  111. c_file = filename if ext in ('.c', '.cpp') else _find_c_source(basename)
  112. if c_file is None:
  113. # a module "pkg/mod.so" can have a source file "pkg/pkg.mod.c"
  114. package_root = find_root_package_dir.uncached(filename)
  115. package_path = os.path.relpath(basename, package_root).split(os.path.sep)
  116. if len(package_path) > 1:
  117. test_basepath = os.path.join(os.path.dirname(filename), '.'.join(package_path))
  118. c_file = _find_c_source(test_basepath)
  119. py_source_file = None
  120. if c_file:
  121. py_source_file = os.path.splitext(c_file)[0] + '.py'
  122. if not os.path.exists(py_source_file):
  123. py_source_file = None
  124. try:
  125. with open(c_file, 'rb') as f:
  126. if b'/* Generated by Cython ' not in f.read(30):
  127. return None, None # not a Cython file
  128. except (IOError, OSError):
  129. c_file = None
  130. return c_file, py_source_file
  131. def _find_c_source_files(self, dir_path, source_file):
  132. """
  133. Desperately parse all C files in the directory or its package parents
  134. (not re-descending) to find the (included) source file in one of them.
  135. """
  136. if not os.path.isdir(dir_path):
  137. return
  138. splitext = os.path.splitext
  139. for filename in os.listdir(dir_path):
  140. ext = splitext(filename)[1].lower()
  141. if ext in ('.c', '.cpp'):
  142. self._parse_lines(os.path.join(dir_path, filename), source_file)
  143. if source_file in self._c_files_map:
  144. return
  145. # not found? then try one package up
  146. if is_package_dir(dir_path):
  147. self._find_c_source_files(os.path.dirname(dir_path), source_file)
  148. def _parse_lines(self, c_file, sourcefile):
  149. """
  150. Parse a Cython generated C/C++ source file and find the executable lines.
  151. Each executable line starts with a comment header that states source file
  152. and line number, as well as the surrounding range of source code lines.
  153. """
  154. if self._parsed_c_files is None:
  155. self._parsed_c_files = {}
  156. if c_file in self._parsed_c_files:
  157. code_lines = self._parsed_c_files[c_file]
  158. else:
  159. match_source_path_line = re.compile(r' */[*] +"(.*)":([0-9]+)$').match
  160. match_current_code_line = re.compile(r' *[*] (.*) # <<<<<<+$').match
  161. match_comment_end = re.compile(r' *[*]/$').match
  162. not_executable = re.compile(
  163. r'\s*c(?:type)?def\s+'
  164. r'(?:(?:public|external)\s+)?'
  165. r'(?:struct|union|enum|class)'
  166. r'(\s+[^:]+|)\s*:'
  167. ).match
  168. code_lines = defaultdict(dict)
  169. filenames = set()
  170. with open(c_file) as lines:
  171. lines = iter(lines)
  172. for line in lines:
  173. match = match_source_path_line(line)
  174. if not match:
  175. continue
  176. filename, lineno = match.groups()
  177. filenames.add(filename)
  178. lineno = int(lineno)
  179. for comment_line in lines:
  180. match = match_current_code_line(comment_line)
  181. if match:
  182. code_line = match.group(1).rstrip()
  183. if not_executable(code_line):
  184. break
  185. code_lines[filename][lineno] = code_line
  186. break
  187. elif match_comment_end(comment_line):
  188. # unexpected comment format - false positive?
  189. break
  190. self._parsed_c_files[c_file] = code_lines
  191. if self._c_files_map is None:
  192. self._c_files_map = {}
  193. for filename, code in code_lines.items():
  194. abs_path = _find_dep_file_path(c_file, filename)
  195. self._c_files_map[abs_path] = (c_file, filename, code)
  196. if sourcefile not in self._c_files_map:
  197. return (None,) * 2 # e.g. shared library file
  198. return self._c_files_map[sourcefile][1:]
  199. class CythonModuleTracer(FileTracer):
  200. """
  201. Find the Python/Cython source file for a Cython module.
  202. """
  203. def __init__(self, module_file, py_file, c_file, c_files_map, file_path_map):
  204. super(CythonModuleTracer, self).__init__()
  205. self.module_file = module_file
  206. self.py_file = py_file
  207. self.c_file = c_file
  208. self._c_files_map = c_files_map
  209. self._file_path_map = file_path_map
  210. def has_dynamic_source_filename(self):
  211. return True
  212. def dynamic_source_filename(self, filename, frame):
  213. """
  214. Determine source file path. Called by the function call tracer.
  215. """
  216. source_file = frame.f_code.co_filename
  217. try:
  218. return self._file_path_map[source_file]
  219. except KeyError:
  220. pass
  221. abs_path = _find_dep_file_path(filename, source_file)
  222. if self.py_file and source_file[-3:].lower() == '.py':
  223. # always let coverage.py handle this case itself
  224. self._file_path_map[source_file] = self.py_file
  225. return self.py_file
  226. assert self._c_files_map is not None
  227. if abs_path not in self._c_files_map:
  228. self._c_files_map[abs_path] = (self.c_file, source_file, None)
  229. self._file_path_map[source_file] = abs_path
  230. return abs_path
  231. class CythonModuleReporter(FileReporter):
  232. """
  233. Provide detailed trace information for one source file to coverage.py.
  234. """
  235. def __init__(self, c_file, source_file, rel_file_path, code):
  236. super(CythonModuleReporter, self).__init__(source_file)
  237. self.name = rel_file_path
  238. self.c_file = c_file
  239. self._code = code
  240. def lines(self):
  241. """
  242. Return set of line numbers that are possibly executable.
  243. """
  244. return set(self._code)
  245. def _iter_source_tokens(self):
  246. current_line = 1
  247. for line_no, code_line in sorted(self._code.items()):
  248. while line_no > current_line:
  249. yield []
  250. current_line += 1
  251. yield [('txt', code_line)]
  252. current_line += 1
  253. def source(self):
  254. """
  255. Return the source code of the file as a string.
  256. """
  257. if os.path.exists(self.filename):
  258. with open_source_file(self.filename) as f:
  259. return f.read()
  260. else:
  261. return '\n'.join(
  262. (tokens[0][1] if tokens else '')
  263. for tokens in self._iter_source_tokens())
  264. def source_token_lines(self):
  265. """
  266. Iterate over the source code tokens.
  267. """
  268. if os.path.exists(self.filename):
  269. with open_source_file(self.filename) as f:
  270. for line in f:
  271. yield [('txt', line.rstrip('\n'))]
  272. else:
  273. for line in self._iter_source_tokens():
  274. yield [('txt', line)]
  275. def coverage_init(reg, options):
  276. reg.add_file_tracer(Plugin())